From cbe73b1e041edfde93c4665ab6dc8dc29dfd0a10 Mon Sep 17 00:00:00 2001 From: lethemanh Date: Thu, 9 Apr 2026 11:38:09 +0700 Subject: [PATCH] #771 implement timezone bottom sheet on mobile --- .../Calendars/TimezoneSelector.test.tsx | 2 +- src/components/Calendar/Calendar.tsx | 2 +- src/components/Calendar/TimezoneSelector.tsx | 111 --------------- .../Timezone/LargeTimeZoneSelector.tsx | 65 +++++++++ .../Timezone/SmallTimeZoneSelector.tsx | 126 ++++++++++++++++++ src/components/Timezone/TimezoneListItem.tsx | 58 ++++++++ src/components/Timezone/TimezoneSelector.tsx | 82 ++++++++++++ .../Timezone/hooks/useTimeZoneList.ts | 20 +++ src/declarations.ts | 12 +- src/features/Settings/GeneralSettings.tsx | 34 +++-- src/locales/en.json | 3 +- src/locales/fr.json | 3 +- src/locales/ru.json | 3 +- src/locales/vi.json | 3 +- src/useScreenSizeDetection.ts | 5 +- 15 files changed, 397 insertions(+), 132 deletions(-) delete mode 100644 src/components/Calendar/TimezoneSelector.tsx create mode 100644 src/components/Timezone/LargeTimeZoneSelector.tsx create mode 100644 src/components/Timezone/SmallTimeZoneSelector.tsx create mode 100644 src/components/Timezone/TimezoneListItem.tsx create mode 100644 src/components/Timezone/TimezoneSelector.tsx create mode 100644 src/components/Timezone/hooks/useTimeZoneList.ts diff --git a/__test__/features/Calendars/TimezoneSelector.test.tsx b/__test__/features/Calendars/TimezoneSelector.test.tsx index b57dc0b..2099015 100644 --- a/__test__/features/Calendars/TimezoneSelector.test.tsx +++ b/__test__/features/Calendars/TimezoneSelector.test.tsx @@ -1,4 +1,4 @@ -import { TimezoneSelector } from '@/components/Calendar/TimezoneSelector' +import { TimezoneSelector } from '@/components/Timezone/TimezoneSelector' import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react' import { renderWithProviders } from '../../utils/Renderwithproviders' diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index d58eaaa..2730c5c 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -36,7 +36,7 @@ import './Calendar.styl' import './CustomCalendar.styl' import { useCalendarEventHandlers } from './hooks/useCalendarEventHandlers' import { useCalendarViewHandlers } from './hooks/useCalendarViewHandlers' -import { TimezoneSelector } from './TimezoneSelector' +import { TimezoneSelector } from '../Timezone/TimezoneSelector' import { eventToFullCalendarFormat, extractEvents, diff --git a/src/components/Calendar/TimezoneSelector.tsx b/src/components/Calendar/TimezoneSelector.tsx deleted file mode 100644 index 7d27295..0000000 --- a/src/components/Calendar/TimezoneSelector.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { - browserDefaultTimeZone, - getTimezoneOffset, - resolveTimezone -} from '@/utils/timezone' -import { TIMEZONES } from '@/utils/timezone-data' -import { Button, Popover } from '@linagora/twake-mui' -import { MouseEvent, useMemo, useRef, useState } from 'react' -import { useI18n } from 'twake-i18n' -import { TimezoneAutocomplete } from '../Timezone/TimezoneAutocomplete' - -interface TimezoneSelectProps { - value: string - onChange: (value: string) => void - referenceDate: Date -} - -export function TimezoneSelector({ - value, - onChange, - referenceDate -}: TimezoneSelectProps) { - const [anchorEl, setAnchorEl] = useState(null) - const inputRef = useRef(null) - - const timezoneList = useTimeZoneList() - - const effectiveTimezone = value - ? resolveTimezone(value) - : timezoneList.browserTz - const selectedOffset = getTimezoneOffset(effectiveTimezone, referenceDate) - - const handleOpen = (event: MouseEvent) => { - setAnchorEl(event.currentTarget) - } - - const handleClose = () => { - setAnchorEl(null) - } - - const open = Boolean(anchorEl) - const { t } = useI18n() - return ( - <> - - - { - inputRef.current?.focus() - } - } - }} - > - - getTimezoneOffset(tzName, referenceDate) - } - inputRef={inputRef} - openOnFocus - showIcon={false} - inputFontSize="14px" - inputPadding="2px 4px" - onClose={handleClose} - disableClearable={true} - /> - - - ) -} - -export function useTimeZoneList() { - return useMemo(() => { - const zones = Object.keys(TIMEZONES.zones).sort() - const browserTz = resolveTimezone(browserDefaultTimeZone) - - return { zones, browserTz, getTimezoneOffset } - }, []) -} diff --git a/src/components/Timezone/LargeTimeZoneSelector.tsx b/src/components/Timezone/LargeTimeZoneSelector.tsx new file mode 100644 index 0000000..6e922b4 --- /dev/null +++ b/src/components/Timezone/LargeTimeZoneSelector.tsx @@ -0,0 +1,65 @@ +import { getTimezoneOffset, resolveTimezone } from '@/utils/timezone' +import { Popover } from '@linagora/twake-mui' +import React, { useRef } from 'react' +import { TimezoneAutocomplete } from '../Timezone/TimezoneAutocomplete' +import { useTimeZoneList } from './hooks/useTimeZoneList' +import { TimezoneSelectProps } from './TimezoneSelector' + +export const LargeTimezoneSelector: React.FC< + TimezoneSelectProps & { + onClose: () => void + open: boolean + anchorEl?: HTMLElement | null + } +> = ({ value, onChange, referenceDate, onClose, open, anchorEl }) => { + const inputRef = useRef(null) + + const timezoneList = useTimeZoneList() + + const effectiveTimezone = value + ? resolveTimezone(value) + : timezoneList.browserTz + + return ( + { + inputRef.current?.focus() + } + } + }} + > + + getTimezoneOffset(tzName, referenceDate) + } + inputRef={inputRef} + openOnFocus + showIcon={false} + inputFontSize="14px" + inputPadding="2px 4px" + onClose={onClose} + disableClearable={true} + /> + + ) +} diff --git a/src/components/Timezone/SmallTimeZoneSelector.tsx b/src/components/Timezone/SmallTimeZoneSelector.tsx new file mode 100644 index 0000000..f6c1324 --- /dev/null +++ b/src/components/Timezone/SmallTimeZoneSelector.tsx @@ -0,0 +1,126 @@ +import { getTimezoneOffset, resolveTimezone } from '@/utils/timezone' +import { + SwipeableDrawer, + Box, + TextField, + List, + InputAdornment +} from '@linagora/twake-mui' +import { Search as SearchIcon } from '@mui/icons-material' +import React, { useState, useMemo, useEffect, useRef } from 'react' +import { useTimeZoneList } from './hooks/useTimeZoneList' +import { TimezoneSelectProps } from './TimezoneSelector' +import { useI18n } from 'twake-i18n' +import { TimezoneListItem } from './TimezoneListItem' + +const filterTimezones = ( + zones: string[], + query: string, + referenceDate: Date +): string[] => { + if (!query) return zones + return zones.filter(tz => { + const label = tz.replace(/_/g, ' ').toLowerCase() + const offset = getTimezoneOffset(tz, referenceDate).toLowerCase() + return label.includes(query) || offset.includes(query) + }) +} + +export const SmallTimezoneSelector: React.FC< + TimezoneSelectProps & { + onClose: () => void + open: boolean + } +> = ({ value, onChange, referenceDate, onClose, open }) => { + const { t } = useI18n() + + const [searchQuery, setSearchQuery] = useState('') + const timezoneList = useTimeZoneList() + + const effectiveTimezone = value + ? resolveTimezone(value) + : timezoneList.browserTz + + const filteredTimeZones = useMemo(() => { + const query = searchQuery.toLowerCase().trim() + return filterTimezones(timezoneList.zones, query, referenceDate) + }, [timezoneList.zones, searchQuery, referenceDate]) + + const handleSelect = (tz: string): void => { + onChange(tz) + setSearchQuery('') + onClose() + } + + const selectedRef = useRef(null) + const inputRef = useRef(null) + + useEffect((): void => { + if (open) { + inputRef.current?.focus() + selectedRef.current?.scrollIntoView({ + behavior: 'auto', + block: 'center' + }) + } + }, [open]) + + return ( + {}} + disableAutoFocus + slotProps={{ + paper: { + sx: { height: '90%' } + } + }} + > + + + ) => setSearchQuery(e.target.value)} + slotProps={{ + htmlInput: { + 'aria-label': t('calendar.searchTimezone') + }, + input: { + startAdornment: ( + + + + ), + disableUnderline: true + } + }} + sx={{ + '& .MuiInputBase-root': { + padding: '8px 0' + } + }} + /> + + + {filteredTimeZones.map(tz => ( + + ))} + + + ) +} diff --git a/src/components/Timezone/TimezoneListItem.tsx b/src/components/Timezone/TimezoneListItem.tsx new file mode 100644 index 0000000..b876630 --- /dev/null +++ b/src/components/Timezone/TimezoneListItem.tsx @@ -0,0 +1,58 @@ +import { getTimezoneOffset } from '@/utils/timezone' +import { + Box, + ListItem, + ListItemButton, + ListItemText, + Typography, + useTheme +} from '@linagora/twake-mui' +import { Check as CheckIcon } from '@mui/icons-material' +import React from 'react' + +interface TimezoneListItemProps { + tz: string + referenceDate: Date + isSelected: boolean + onSelect: (tz: string) => void + selectedRef: React.RefObject | null +} + +export const TimezoneListItem: React.FC = ({ + tz, + referenceDate, + isSelected, + onSelect, + selectedRef +}) => { + const theme = useTheme() + const offset = getTimezoneOffset(tz, referenceDate) + const label = tz.split('/').pop()?.replace(/_/g, ' ') || tz + + return ( + + onSelect(tz)} + sx={{ py: 1 }} + > + + + ({offset}) {label} + + + } + /> + {isSelected && ( + + )} + + + ) +} diff --git a/src/components/Timezone/TimezoneSelector.tsx b/src/components/Timezone/TimezoneSelector.tsx new file mode 100644 index 0000000..404bb82 --- /dev/null +++ b/src/components/Timezone/TimezoneSelector.tsx @@ -0,0 +1,82 @@ +import { getTimezoneOffset, resolveTimezone } from '@/utils/timezone' +import { Button } from '@linagora/twake-mui' +import { MouseEvent, useState } from 'react' +import { useI18n } from 'twake-i18n' +import { useTimeZoneList } from './hooks/useTimeZoneList' +import { LargeTimezoneSelector } from './LargeTimeZoneSelector' +import { useScreenSizeDetection } from '@/useScreenSizeDetection' +import { SmallTimezoneSelector } from './SmallTimeZoneSelector' + +export interface TimezoneSelectProps { + value: string + onChange: (value: string) => void + referenceDate: Date +} + +export const TimezoneSelector: React.FC = ({ + value, + onChange, + referenceDate +}) => { + const { isTooSmall: isMobile } = useScreenSizeDetection() + + const [anchorEl, setAnchorEl] = useState(null) + + const timezoneList = useTimeZoneList() + + const effectiveTimezone = value + ? resolveTimezone(value) + : timezoneList.browserTz + const safeTimezone = timezoneList.zones.includes(effectiveTimezone) + ? effectiveTimezone + : timezoneList.browserTz + const selectedOffset = getTimezoneOffset(safeTimezone, referenceDate) + + const handleOpen = (event: MouseEvent): void => { + setAnchorEl(event.currentTarget) + } + + const handleClose = (): void => { + setAnchorEl(null) + } + + const open = Boolean(anchorEl) + const { t } = useI18n() + return ( + <> + + + {isMobile ? ( + + ) : ( + + )} + + ) +} diff --git a/src/components/Timezone/hooks/useTimeZoneList.ts b/src/components/Timezone/hooks/useTimeZoneList.ts new file mode 100644 index 0000000..65dd798 --- /dev/null +++ b/src/components/Timezone/hooks/useTimeZoneList.ts @@ -0,0 +1,20 @@ +import { + browserDefaultTimeZone, + getTimezoneOffset, + resolveTimezone +} from '@/utils/timezone' +import { TIMEZONES } from '@/utils/timezone-data' +import { useMemo } from 'react' + +export function useTimeZoneList(): { + zones: string[] + browserTz: string + getTimezoneOffset: (tzName: string, date?: Date) => string +} { + return useMemo(() => { + const zones = Object.keys(TIMEZONES.zones).sort() + const browserTz = resolveTimezone(browserDefaultTimeZone) + + return { zones, browserTz, getTimezoneOffset } + }, []) +} diff --git a/src/declarations.ts b/src/declarations.ts index c7f957c..9a22528 100644 --- a/src/declarations.ts +++ b/src/declarations.ts @@ -1,11 +1,17 @@ +declare module '*.styl' + declare module '@linagora/twake-mui' { export * from '@mui/material' - import type { AvatarProps as MuiAvatarProps } from '@mui/material' + + export function useTheme(): T export type AvatarSize = 'xs' | 's' | 'm' | 'l' | 'xl' export type AvatarDisplay = 'initial' | 'inline' - export interface AvatarProps extends Omit { + export interface AvatarProps extends Omit< + import('@mui/material').AvatarProps, + 'color' + > { color?: string size?: AvatarSize | number border?: boolean @@ -14,7 +20,7 @@ declare module '@linagora/twake-mui' { display?: AvatarDisplay } - export const Avatar: React.FC + export const Avatar: import('react').FC export const radius: Record } diff --git a/src/features/Settings/GeneralSettings.tsx b/src/features/Settings/GeneralSettings.tsx index 79d1b84..0a5a19a 100644 --- a/src/features/Settings/GeneralSettings.tsx +++ b/src/features/Settings/GeneralSettings.tsx @@ -1,5 +1,5 @@ import { useAppDispatch, useAppSelector } from '@/app/hooks' -import { useTimeZoneList } from '@/components/Calendar/TimezoneSelector' +import { useTimeZoneList } from '@/components/Timezone/hooks/useTimeZoneList' import { WeekDaySelector } from '@/components/Event/WeekDaySelector' import { TimezoneAutocomplete } from '@/components/Timezone/TimezoneAutocomplete' import { browserDefaultTimeZone, getTimezoneOffset } from '@/utils/timezone' @@ -9,7 +9,6 @@ import { FormControlLabel, MenuItem, Select, - SelectChangeEvent, Switch, Typography } from '@linagora/twake-mui' @@ -40,13 +39,13 @@ interface GeneralSettingsProps { onWorkingDaysError: () => void } -export function GeneralSettings({ +export const GeneralSettings: React.FC = ({ onLanguageError, onTimeZoneError, onHideDeclinedEventsError, onDisplayWeekNumbersError, onWorkingDaysError -}: GeneralSettingsProps) { +}) => { const dispatch = useAppDispatch() const { t } = useI18n() @@ -79,7 +78,20 @@ export function GeneralSettings({ null ) - const handleLanguageChange = (event: SelectChangeEvent) => { + const handleLanguageChange = ( + event: + | React.ChangeEvent< + Omit & { + value: string + } + > + | (Event & { + target: { + value: string + name: string + } + }) + ): void => { const newLanguage = event.target.value const previousLanguage = currentLanguage dispatch(setUserLanguage(newLanguage)) @@ -93,7 +105,7 @@ export function GeneralSettings({ }) } - const handleTimeZoneChange = (newTimeZone: string) => { + const handleTimeZoneChange = (newTimeZone: string): void => { const previousTimeZone = currentTimeZone dispatch(setUserTimeZone(newTimeZone)) dispatch(setSettingsTimeZone(newTimeZone)) @@ -108,7 +120,7 @@ export function GeneralSettings({ }) } - const handleTimeZoneDefaultChange = (isDefault: boolean) => { + const handleTimeZoneDefaultChange = (isDefault: boolean): void => { const previousTimeZone = currentTimeZone dispatch(setIsBrowserDefaultTimeZone(isDefault)) if (isDefault) { @@ -127,7 +139,7 @@ export function GeneralSettings({ } } - const handleHideDeclinedEvents = (value: boolean) => { + const handleHideDeclinedEvents = (value: boolean): void => { dispatch(setHideDeclinedEvents(value)) dispatch(updateUserConfigurationsAsync({ hideDeclinedEvents: value })) .unwrap() @@ -137,7 +149,7 @@ export function GeneralSettings({ }) } - const handleDisplayWeekNumbers = (value: boolean) => { + const handleDisplayWeekNumbers = (value: boolean): void => { dispatch(setDisplayWeekNumbers(value)) dispatch(updateUserConfigurationsAsync({ displayWeekNumbers: value })) .unwrap() @@ -178,14 +190,14 @@ export function GeneralSettings({ ) useEffect(() => { - return () => { + return (): void => { if (businessHoursTimeoutRef.current) { clearTimeout(businessHoursTimeoutRef.current) } } }, []) - const handleWorkingDays = (value: boolean) => { + const handleWorkingDays = (value: boolean): void => { dispatch(setWorkingDays(value)) dispatch(updateUserConfigurationsAsync({ workingDays: value })) .unwrap() diff --git a/src/locales/en.json b/src/locales/en.json index 3b1b588..a0233f5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -27,7 +27,8 @@ "sharedWarning": "You will lose access to its events. You will still be able to add it back later." }, "resources": "Resources", - "browseResources": "Browse resources" + "browseResources": "Browse resources", + "searchTimezone": "Search timezone..." }, "actions": { "modify": "Modify", diff --git a/src/locales/fr.json b/src/locales/fr.json index 6f41182..b291e74 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -27,7 +27,8 @@ "sharedWarning": "Vous allez perdre l’accès à ses événements. Vous pourrez toujours le rajouter plus tard." }, "resources": "Ressources", - "browseResources": "Parcourir les ressources" + "browseResources": "Parcourir les ressources", + "searchTimezone": "Rechercher un fuseau horaire..." }, "actions": { "modify": "Modifier", diff --git a/src/locales/ru.json b/src/locales/ru.json index 88aa5d3..de3b561 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -27,7 +27,8 @@ "sharedWarning": "Вы потеряете доступ. Позже можно добавить снова." }, "resources": "Ресурсы", - "browseResources": "Просмотреть ресурсы" + "browseResources": "Просмотреть ресурсы", + "searchTimezone": "Поиск часового пояса..." }, "actions": { "modify": "Изменить", diff --git a/src/locales/vi.json b/src/locales/vi.json index a94a84e..5aa5e1c 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -27,7 +27,8 @@ "sharedWarning": "Bạn sẽ mất quyền truy cập các sự kiện của lịch. Bạn vẫn có thể thêm lại sau." }, "resources": "Tài nguyên", - "browseResources": "Tìm kiếm tài nguyên" + "browseResources": "Tìm kiếm tài nguyên", + "searchTimezone": "Tìm kiếm múi giờ..." }, "actions": { "modify": "Chỉnh sửa", diff --git a/src/useScreenSizeDetection.ts b/src/useScreenSizeDetection.ts index 954dcf5..ae316ca 100644 --- a/src/useScreenSizeDetection.ts +++ b/src/useScreenSizeDetection.ts @@ -1,6 +1,9 @@ import { useMediaQuery, useTheme } from '@linagora/twake-mui' -export function useScreenSizeDetection() { +export function useScreenSizeDetection(): { + isTooSmall: boolean + isTablet: boolean +} { const theme = useTheme() const isDesktop = useMediaQuery(theme.breakpoints.up('lg'))