#771 implement timezone bottom sheet on mobile
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}, [])
|
||||
}
|
||||
Reference in New Issue
Block a user