Merge pull request #752 from lethemanh/771-timezone-bottom-sheet

771 timezone bottom sheet
This commit is contained in:
lethemanh
2026-04-09 15:12:23 +07:00
committed by GitHub
15 changed files with 397 additions and 132 deletions
@@ -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 { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../../utils/Renderwithproviders' import { renderWithProviders } from '../../utils/Renderwithproviders'
+1 -1
View File
@@ -36,7 +36,7 @@ import './Calendar.styl'
import './CustomCalendar.styl' import './CustomCalendar.styl'
import { useCalendarEventHandlers } from './hooks/useCalendarEventHandlers' import { useCalendarEventHandlers } from './hooks/useCalendarEventHandlers'
import { useCalendarViewHandlers } from './hooks/useCalendarViewHandlers' import { useCalendarViewHandlers } from './hooks/useCalendarViewHandlers'
import { TimezoneSelector } from './TimezoneSelector' import { TimezoneSelector } from '../Timezone/TimezoneSelector'
import { import {
eventToFullCalendarFormat, eventToFullCalendarFormat,
extractEvents, extractEvents,
@@ -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<HTMLElement | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const timezoneList = useTimeZoneList()
const effectiveTimezone = value
? resolveTimezone(value)
: timezoneList.browserTz
const selectedOffset = getTimezoneOffset(effectiveTimezone, referenceDate)
const handleOpen = (event: MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const open = Boolean(anchorEl)
const { t } = useI18n()
return (
<>
<Button
variant="text"
size="small"
onClick={handleOpen}
sx={{
textTransform: 'none',
minWidth: 'auto',
padding: '2px 4px',
margin: 0,
lineHeight: 1.2
}}
>
{selectedOffset || t('common.select_timezone')}
</Button>
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left'
}}
slotProps={{
paper: {
sx: { width: 280, maxHeight: 400, overflow: 'hidden', p: 0 }
},
transition: {
onEntered: () => {
inputRef.current?.focus()
}
}
}}
>
<TimezoneAutocomplete
size="medium"
value={effectiveTimezone}
onChange={onChange}
zones={timezoneList.zones}
getTimezoneOffset={(tzName: string) =>
getTimezoneOffset(tzName, referenceDate)
}
inputRef={inputRef}
openOnFocus
showIcon={false}
inputFontSize="14px"
inputPadding="2px 4px"
onClose={handleClose}
disableClearable={true}
/>
</Popover>
</>
)
}
export function useTimeZoneList() {
return useMemo(() => {
const zones = Object.keys(TIMEZONES.zones).sort()
const browserTz = resolveTimezone(browserDefaultTimeZone)
return { zones, browserTz, getTimezoneOffset }
}, [])
}
@@ -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<HTMLInputElement>(null)
const timezoneList = useTimeZoneList()
const effectiveTimezone = value
? resolveTimezone(value)
: timezoneList.browserTz
return (
<Popover
open={open}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left'
}}
slotProps={{
paper: {
sx: { width: 280, maxHeight: 400, overflow: 'hidden', p: 0 }
},
transition: {
onEntered: () => {
inputRef.current?.focus()
}
}
}}
>
<TimezoneAutocomplete
size="medium"
value={effectiveTimezone}
onChange={onChange}
zones={timezoneList.zones}
getTimezoneOffset={(tzName: string) =>
getTimezoneOffset(tzName, referenceDate)
}
inputRef={inputRef}
openOnFocus
showIcon={false}
inputFontSize="14px"
inputPadding="2px 4px"
onClose={onClose}
disableClearable={true}
/>
</Popover>
)
}
@@ -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<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect((): void => {
if (open) {
inputRef.current?.focus()
selectedRef.current?.scrollIntoView({
behavior: 'auto',
block: 'center'
})
}
}, [open])
return (
<SwipeableDrawer
anchor="bottom"
open={open}
onClose={onClose}
onOpen={(): void => {}}
disableAutoFocus
slotProps={{
paper: {
sx: { height: '90%' }
}
}}
>
<Box sx={{ px: 2 }}>
<TextField
inputRef={inputRef}
autoFocus
fullWidth
variant="standard"
placeholder={t('calendar.searchTimezone')}
value={searchQuery}
onChange={(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => setSearchQuery(e.target.value)}
slotProps={{
htmlInput: {
'aria-label': t('calendar.searchTimezone')
},
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{ color: 'action.active', mr: 1 }} />
</InputAdornment>
),
disableUnderline: true
}
}}
sx={{
'& .MuiInputBase-root': {
padding: '8px 0'
}
}}
/>
</Box>
<List sx={{ overflow: 'auto', flex: 1, pt: 0 }}>
{filteredTimeZones.map(tz => (
<TimezoneListItem
key={tz}
tz={tz}
referenceDate={referenceDate}
isSelected={effectiveTimezone === tz}
onSelect={handleSelect}
selectedRef={selectedRef}
/>
))}
</List>
</SwipeableDrawer>
)
}
@@ -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<HTMLDivElement> | null
}
export const TimezoneListItem: React.FC<TimezoneListItemProps> = ({
tz,
referenceDate,
isSelected,
onSelect,
selectedRef
}) => {
const theme = useTheme()
const offset = getTimezoneOffset(tz, referenceDate)
const label = tz.split('/').pop()?.replace(/_/g, ' ') || tz
return (
<ListItem disablePadding>
<ListItemButton
selected={isSelected}
aria-selected={isSelected}
ref={isSelected ? selectedRef : null}
onClick={() => onSelect(tz)}
sx={{ py: 1 }}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography>
({offset}) {label}
</Typography>
</Box>
}
/>
{isSelected && (
<CheckIcon
sx={{ color: theme.palette.primary.main, fontSize: '20px' }}
/>
)}
</ListItemButton>
</ListItem>
)
}
@@ -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<TimezoneSelectProps> = ({
value,
onChange,
referenceDate
}) => {
const { isTooSmall: isMobile } = useScreenSizeDetection()
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(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<HTMLElement>): void => {
setAnchorEl(event.currentTarget)
}
const handleClose = (): void => {
setAnchorEl(null)
}
const open = Boolean(anchorEl)
const { t } = useI18n()
return (
<>
<Button
variant="text"
size="small"
onClick={handleOpen}
sx={{
textTransform: 'none',
minWidth: 'auto',
padding: '2px 4px',
margin: 0,
lineHeight: 1.2
}}
>
{selectedOffset || t('common.select_timezone')}
</Button>
{isMobile ? (
<SmallTimezoneSelector
value={value}
onChange={onChange}
referenceDate={referenceDate}
onClose={handleClose}
open={open}
/>
) : (
<LargeTimezoneSelector
value={value}
onChange={onChange}
referenceDate={referenceDate}
onClose={handleClose}
open={open}
anchorEl={anchorEl}
/>
)}
</>
)
}
@@ -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 }
}, [])
}
+9 -3
View File
@@ -1,11 +1,17 @@
declare module '*.styl'
declare module '@linagora/twake-mui' { declare module '@linagora/twake-mui' {
export * from '@mui/material' export * from '@mui/material'
import type { AvatarProps as MuiAvatarProps } from '@mui/material'
export function useTheme<T = import('@mui/material').Theme>(): T
export type AvatarSize = 'xs' | 's' | 'm' | 'l' | 'xl' export type AvatarSize = 'xs' | 's' | 'm' | 'l' | 'xl'
export type AvatarDisplay = 'initial' | 'inline' export type AvatarDisplay = 'initial' | 'inline'
export interface AvatarProps extends Omit<MuiAvatarProps, 'color'> { export interface AvatarProps extends Omit<
import('@mui/material').AvatarProps,
'color'
> {
color?: string color?: string
size?: AvatarSize | number size?: AvatarSize | number
border?: boolean border?: boolean
@@ -14,7 +20,7 @@ declare module '@linagora/twake-mui' {
display?: AvatarDisplay display?: AvatarDisplay
} }
export const Avatar: React.FC<AvatarProps> export const Avatar: import('react').FC<AvatarProps>
export const radius: Record<string, string | number> export const radius: Record<string, string | number>
} }
+23 -11
View File
@@ -1,5 +1,5 @@
import { useAppDispatch, useAppSelector } from '@/app/hooks' 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 { WeekDaySelector } from '@/components/Event/WeekDaySelector'
import { TimezoneAutocomplete } from '@/components/Timezone/TimezoneAutocomplete' import { TimezoneAutocomplete } from '@/components/Timezone/TimezoneAutocomplete'
import { browserDefaultTimeZone, getTimezoneOffset } from '@/utils/timezone' import { browserDefaultTimeZone, getTimezoneOffset } from '@/utils/timezone'
@@ -9,7 +9,6 @@ import {
FormControlLabel, FormControlLabel,
MenuItem, MenuItem,
Select, Select,
SelectChangeEvent,
Switch, Switch,
Typography Typography
} from '@linagora/twake-mui' } from '@linagora/twake-mui'
@@ -40,13 +39,13 @@ interface GeneralSettingsProps {
onWorkingDaysError: () => void onWorkingDaysError: () => void
} }
export function GeneralSettings({ export const GeneralSettings: React.FC<GeneralSettingsProps> = ({
onLanguageError, onLanguageError,
onTimeZoneError, onTimeZoneError,
onHideDeclinedEventsError, onHideDeclinedEventsError,
onDisplayWeekNumbersError, onDisplayWeekNumbersError,
onWorkingDaysError onWorkingDaysError
}: GeneralSettingsProps) { }) => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { t } = useI18n() const { t } = useI18n()
@@ -79,7 +78,20 @@ export function GeneralSettings({
null null
) )
const handleLanguageChange = (event: SelectChangeEvent<string>) => { const handleLanguageChange = (
event:
| React.ChangeEvent<
Omit<HTMLInputElement, 'value'> & {
value: string
}
>
| (Event & {
target: {
value: string
name: string
}
})
): void => {
const newLanguage = event.target.value const newLanguage = event.target.value
const previousLanguage = currentLanguage const previousLanguage = currentLanguage
dispatch(setUserLanguage(newLanguage)) dispatch(setUserLanguage(newLanguage))
@@ -93,7 +105,7 @@ export function GeneralSettings({
}) })
} }
const handleTimeZoneChange = (newTimeZone: string) => { const handleTimeZoneChange = (newTimeZone: string): void => {
const previousTimeZone = currentTimeZone const previousTimeZone = currentTimeZone
dispatch(setUserTimeZone(newTimeZone)) dispatch(setUserTimeZone(newTimeZone))
dispatch(setSettingsTimeZone(newTimeZone)) dispatch(setSettingsTimeZone(newTimeZone))
@@ -108,7 +120,7 @@ export function GeneralSettings({
}) })
} }
const handleTimeZoneDefaultChange = (isDefault: boolean) => { const handleTimeZoneDefaultChange = (isDefault: boolean): void => {
const previousTimeZone = currentTimeZone const previousTimeZone = currentTimeZone
dispatch(setIsBrowserDefaultTimeZone(isDefault)) dispatch(setIsBrowserDefaultTimeZone(isDefault))
if (isDefault) { if (isDefault) {
@@ -127,7 +139,7 @@ export function GeneralSettings({
} }
} }
const handleHideDeclinedEvents = (value: boolean) => { const handleHideDeclinedEvents = (value: boolean): void => {
dispatch(setHideDeclinedEvents(value)) dispatch(setHideDeclinedEvents(value))
dispatch(updateUserConfigurationsAsync({ hideDeclinedEvents: value })) dispatch(updateUserConfigurationsAsync({ hideDeclinedEvents: value }))
.unwrap() .unwrap()
@@ -137,7 +149,7 @@ export function GeneralSettings({
}) })
} }
const handleDisplayWeekNumbers = (value: boolean) => { const handleDisplayWeekNumbers = (value: boolean): void => {
dispatch(setDisplayWeekNumbers(value)) dispatch(setDisplayWeekNumbers(value))
dispatch(updateUserConfigurationsAsync({ displayWeekNumbers: value })) dispatch(updateUserConfigurationsAsync({ displayWeekNumbers: value }))
.unwrap() .unwrap()
@@ -178,14 +190,14 @@ export function GeneralSettings({
) )
useEffect(() => { useEffect(() => {
return () => { return (): void => {
if (businessHoursTimeoutRef.current) { if (businessHoursTimeoutRef.current) {
clearTimeout(businessHoursTimeoutRef.current) clearTimeout(businessHoursTimeoutRef.current)
} }
} }
}, []) }, [])
const handleWorkingDays = (value: boolean) => { const handleWorkingDays = (value: boolean): void => {
dispatch(setWorkingDays(value)) dispatch(setWorkingDays(value))
dispatch(updateUserConfigurationsAsync({ workingDays: value })) dispatch(updateUserConfigurationsAsync({ workingDays: value }))
.unwrap() .unwrap()
+2 -1
View File
@@ -27,7 +27,8 @@
"sharedWarning": "You will lose access to its events. You will still be able to add it back later." "sharedWarning": "You will lose access to its events. You will still be able to add it back later."
}, },
"resources": "Resources", "resources": "Resources",
"browseResources": "Browse resources" "browseResources": "Browse resources",
"searchTimezone": "Search timezone..."
}, },
"actions": { "actions": {
"modify": "Modify", "modify": "Modify",
+2 -1
View File
@@ -27,7 +27,8 @@
"sharedWarning": "Vous allez perdre laccès à ses événements. Vous pourrez toujours le rajouter plus tard." "sharedWarning": "Vous allez perdre laccès à ses événements. Vous pourrez toujours le rajouter plus tard."
}, },
"resources": "Ressources", "resources": "Ressources",
"browseResources": "Parcourir les ressources" "browseResources": "Parcourir les ressources",
"searchTimezone": "Rechercher un fuseau horaire..."
}, },
"actions": { "actions": {
"modify": "Modifier", "modify": "Modifier",
+2 -1
View File
@@ -27,7 +27,8 @@
"sharedWarning": "Вы потеряете доступ. Позже можно добавить снова." "sharedWarning": "Вы потеряете доступ. Позже можно добавить снова."
}, },
"resources": "Ресурсы", "resources": "Ресурсы",
"browseResources": "Просмотреть ресурсы" "browseResources": "Просмотреть ресурсы",
"searchTimezone": "Поиск часового пояса..."
}, },
"actions": { "actions": {
"modify": "Изменить", "modify": "Изменить",
+2 -1
View File
@@ -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." "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", "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": { "actions": {
"modify": "Chỉnh sửa", "modify": "Chỉnh sửa",
+4 -1
View File
@@ -1,6 +1,9 @@
import { useMediaQuery, useTheme } from '@linagora/twake-mui' import { useMediaQuery, useTheme } from '@linagora/twake-mui'
export function useScreenSizeDetection() { export function useScreenSizeDetection(): {
isTooSmall: boolean
isTablet: boolean
} {
const theme = useTheme() const theme = useTheme()
const isDesktop = useMediaQuery(theme.breakpoints.up('lg')) const isDesktop = useMediaQuery(theme.breakpoints.up('lg'))