diff --git a/__test__/components/DatePickerMobile.test.tsx b/__test__/components/DatePickerMobile.test.tsx new file mode 100644 index 0000000..b1bfc36 --- /dev/null +++ b/__test__/components/DatePickerMobile.test.tsx @@ -0,0 +1,180 @@ +import { CalendarApi } from '@fullcalendar/core' +import { + act, + render, + screen, + fireEvent, + waitFor, + RenderResult +} from '@testing-library/react' +import React from 'react' +import { DatePickerMobile } from '@/components/Menubar/components/DatePickerMobile' + +jest.mock('twake-i18n', () => ({ + useI18n: (): { t: (key: string) => string; lang: string } => ({ + t: (key: string) => key, + lang: 'en' + }) +})) + +jest.mock('@linagora/twake-mui', () => { + const actual = jest.requireActual( + '@linagora/twake-mui' + ) + return { + ...actual, + useTheme: (): { palette: { grey: { 500: string } } } => ({ + palette: { grey: { 500: '#9e9e9e' } } + }) + } +}) + +const scrollIntoViewMock = jest.fn() +const originalScrollIntoView = window.HTMLElement.prototype.scrollIntoView + +beforeAll(() => { + window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock +}) + +afterAll(() => { + window.HTMLElement.prototype.scrollIntoView = originalScrollIntoView +}) + +function makeCalendarApi(initialDate: Date): CalendarApi & { + getDate: jest.Mock + gotoDate: jest.Mock +} { + let _date = initialDate + const mockApi = { + getDate: jest.fn(() => _date), + gotoDate: jest.fn((d: Date) => { + _date = d + }) + } + return mockApi as unknown as CalendarApi & typeof mockApi +} + +describe('DatePickerMobile', () => { + beforeEach(() => { + jest.clearAllMocks() + scrollIntoViewMock.mockClear() + }) + + const setup = async ( + initialDate: Date + ): Promise< + RenderResult & { calendarApi: CalendarApi; onDateChange: jest.Mock } + > => { + const calendarApi = makeCalendarApi(initialDate) + const onDateChange = jest.fn() + const renderResult = await act(() => + render( + + ) + ) + return { ...renderResult, calendarApi, onDateChange } + } + + const expectNavigatedDate = ( + calendarApi: CalendarApi, + expected: { day: number; month: number; year: number } + ): void => { + // eslint-disable-next-line @typescript-eslint/unbound-method + const gotoDateMock = calendarApi.gotoDate as jest.Mock + const navigatedDate: Date = gotoDateMock.mock.calls[0][0] + expect(navigatedDate.getDate()).toBe(expected.day) + expect(navigatedDate.getMonth()).toBe(expected.month) + expect(navigatedDate.getFullYear()).toBe(expected.year) + } + + const testMonthNavigation = async ( + initialDate: Date, + monthToClick: number, + expected: { day: number; month: number; year: number } + ): Promise => { + const { calendarApi } = await setup(initialDate) + + const monthButton = screen.getByText(`months.short.${monthToClick}`) + act(() => { + fireEvent.click(monthButton) + }) + + await waitFor(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(calendarApi.gotoDate).toHaveBeenCalled() + }) + + expectNavigatedDate(calendarApi, expected) + } + + it('calls calendarApi.gotoDate and onDateChange with the selected date', async () => { + const initialDate = new Date(2025, 5, 15) // June 15, 2025 + const { calendarApi, onDateChange } = await setup(initialDate) + + const day20 = screen.getByRole('gridcell', { name: '20' }) + act(() => { + fireEvent.click(day20) + }) + + await waitFor(() => { + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(calendarApi.gotoDate).toHaveBeenCalled() + }) + + expectNavigatedDate(calendarApi, { day: 20, month: 5, year: 2025 }) + // eslint-disable-next-line @typescript-eslint/unbound-method + const gotoDateMock = calendarApi.gotoDate as jest.Mock + expect(onDateChange).toHaveBeenCalledWith(gotoDateMock.mock.calls[0][0]) + }) + + it('navigates to the clicked month while keeping the current day', async () => { + await testMonthNavigation( + new Date(2025, 5, 15), // June 15, 2025 + 8, + { day: 15, month: 8, year: 2025 } + ) + }) + + it('clamps the day to the last valid day when switching to a shorter month', async () => { + await testMonthNavigation( + new Date(2025, 0, 31), // Jan 31, 2025 + 1, + { day: 28, month: 1, year: 2025 } + ) + }) + + it('scrollIntoView is called on the correct month button when currentDate changes to another month', async () => { + const initialDate = new Date(2025, 5, 15) // June 15, 2025 (month index 5) + const { rerender, calendarApi, onDateChange } = await setup(initialDate) + + // Simulate parent updating currentDate to August (e.g. user picked a day in August) + const newDate = new Date(2025, 7, 5) // August 5, 2025 (month index 7) + + act(() => { + rerender( + + ) + }) + + await waitFor(() => { + // scrollIntoView should have been called at least once (initial mount + rerender) + expect(scrollIntoViewMock).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'nearest', + inline: 'center' + }) + }) + + // The last call should correspond to August (index 7) button being highlighted + const augustButton = screen.getByText('months.short.7') + expect(augustButton).toBeInTheDocument() + }) +}) diff --git a/src/App.tsx b/src/App.tsx index 13db4b8..8996e1a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,8 +14,6 @@ import { AVAILABLE_LANGUAGES } from './features/Settings/constants' import { default as HandleLogin } from './features/User/HandleLogin' import { CallbackResume } from './features/User/LoginCallback' import { useInitializeApp } from './features/User/useInitializeApp' -import { ScreenTooSmall } from './ScreenTooSmall' -import { useScreenSizeDetection } from './useScreenSizeDetection' import { WebSocketGate } from './websocket/WebSocketGate' import { @@ -65,8 +63,6 @@ function App(): JSX.Element { useInitializeApp() - const { isTooSmall } = useScreenSizeDetection() - return ( - {isTooSmall ? ( - - ) : ( - <> - }> - - - - } /> - } /> - } /> - } /> - - - - - {appLoading && } - - )} + }> + + + + } /> + } /> + } /> + } /> + + + + + {appLoading && } ) diff --git a/src/components/Calendar/Calendar.styl b/src/components/Calendar/Calendar.styl index 21d331a..cd58d82 100644 --- a/src/components/Calendar/Calendar.styl +++ b/src/components/Calendar/Calendar.styl @@ -87,6 +87,9 @@ align-items center padding 0.5rem 0 font-family "Inter", sans-serif + gap 6px + &--mobile + flex-direction column-reverse !important /* sidebar*/ .sidebar-calendar diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index 6e367d6..c7ca232 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -63,7 +63,7 @@ interface CalendarAppProps { currentView: string } -export default function CalendarApp({ +const CalendarApp: React.FC = ({ calendarRef, onDateChange, onViewChange, @@ -72,12 +72,12 @@ export default function CalendarApp({ onCloseSidebar, setCurrentView, currentView -}: CalendarAppProps): JSX.Element { +}: CalendarAppProps) => { const [selectedDate, setSelectedDate] = useState(new Date()) const [debouncedDate, setDebouncedDate] = useState(new Date()) useEffect(() => { const t = setTimeout(() => setDebouncedDate(selectedDate), 300) - return () => clearTimeout(t) + return (): void => clearTimeout(t) }, [selectedDate]) const [selectedMiniDate, setSelectedMiniDate] = useState(new Date()) const userId = useAppSelector(state => state.user.userData?.openpaasId) ?? '' @@ -93,7 +93,7 @@ export default function CalendarApp({ state => state.settings.hideDeclinedEvents ) - const { isTablet } = useScreenSizeDetection() + const { isTablet, isTooSmall: isMobile } = useScreenSizeDetection() const hiddenDays = useMemo(() => { if (!hideWorkingDays || !workingDays || workingDays.length === 0) return [] @@ -135,16 +135,16 @@ export default function CalendarApp({ useEffect(() => { const handler = errorHandler.current handler.setErrorCallback(setEventErrors) - return () => handler.setErrorCallback(() => {}) + return (): void => handler.setErrorCallback(() => {}) }, []) - const handleErrorClose = () => { + const handleErrorClose = (): void => { setEventErrors([]) errorHandler.current.clearAll() } useEffect(() => { - const updateSelectedCalendars = () => { + const updateSelectedCalendars = (): void => { if (initialLoadRef.current && calendarIds.length > 0 && userId) { const cached = localStorage.getItem('selectedCalendars') if (cached && cached.length > 0) { @@ -171,7 +171,7 @@ export default function CalendarApp({ }, [selectedCalendars, calendarIds.length]) useEffect(() => { - const updateSelectedCalendarsOnCalendarChange = () => { + const updateSelectedCalendarsOnCalendarChange = (): void => { if (calendarIds.length === 0) return const validCalendarIds = new Set(calendarIds) setSelectedCalendars(prev => { @@ -237,7 +237,13 @@ export default function CalendarApp({ // Listen for eventModalError event to reopen modal on API failure useEffect(() => { - const handleEventModalError = (event: CustomEvent) => { + const handleEventModalError = (e: Event): void => { + const event = e as CustomEvent<{ + type: 'create' | 'update' + eventId: string + calId: string + typeOfAction: string + }> if (event.detail?.type === 'create') { // Reopen create event modal setAnchorEl(document.body) @@ -286,7 +292,7 @@ export default function CalendarApp({ 'eventModalError', handleEventModalError as EventListener ) - return () => { + return (): void => { window.removeEventListener( 'eventModalError', handleEventModalError as EventListener @@ -319,7 +325,7 @@ export default function CalendarApp({ calendarRef.current?.changeView(targetView) } }) - return () => cancelAnimationFrame(id) + return (): void => cancelAnimationFrame(id) }, [view, isTablet, currentView, calendarRef]) // Event handlers const eventHandlers = useCalendarEventHandlers({ @@ -541,7 +547,9 @@ export default function CalendarApp({ .toUpperCase() return ( -
+
{weekDay} {arg.view.type !== CALENDAR_VIEWS.dayGridMonth && ( { + void eventHandlers.handleEventDrop(arg) + }} + eventResize={arg => { + void eventHandlers.handleEventResize(arg) + }} eventContent={viewHandlers.handleEventContent} eventDidMount={viewHandlers.handleEventDidMount} /> @@ -599,3 +611,5 @@ export default function CalendarApp({ ) } + +export default CalendarApp diff --git a/src/components/Calendar/CalendarLayout.tsx b/src/components/Calendar/CalendarLayout.tsx index 2dcbd74..6293780 100644 --- a/src/components/Calendar/CalendarLayout.tsx +++ b/src/components/Calendar/CalendarLayout.tsx @@ -12,7 +12,7 @@ import CalendarApp from './Calendar' import { CALENDAR_VIEWS } from './utils/constants' import { setView } from '@/features/Settings/SettingsSlice' -export default function CalendarLayout() { +export default function CalendarLayout(): JSX.Element { const calendarRef = useRef(null) const dispatch = useAppDispatch() const error = useAppSelector(state => state.calendars.error) @@ -20,33 +20,28 @@ export default function CalendarLayout() { const tempcalendars = useAppSelector(state => state.calendars.templist) const view = useAppSelector(state => state.settings.view) - const { isTablet } = useScreenSizeDetection() + const { isTablet, isTooSmall: isMobile } = useScreenSizeDetection() const [openSidebar, setOpenSideBar] = useState(false) const [currentDate, setCurrentDate] = useState(new Date()) const [currentView, setCurrentView] = useState( - isTablet ? CALENDAR_VIEWS.timeGridDay : CALENDAR_VIEWS.timeGridWeek + isTablet || isMobile + ? CALENDAR_VIEWS.timeGridDay + : CALENDAR_VIEWS.timeGridWeek ) useEffect(() => { - const setView = () => - setCurrentView(prev => { - if ( - prev !== CALENDAR_VIEWS.timeGridDay && - prev !== CALENDAR_VIEWS.timeGridWeek - ) { - return prev - } - - return isTablet + const setView = (): void => + setCurrentView( + isTablet || isMobile ? CALENDAR_VIEWS.timeGridDay : CALENDAR_VIEWS.timeGridWeek - }) + ) setView() - }, [isTablet]) + }, [isTablet, isMobile]) const isInIframe = useMemo(() => new CozyBridge().isInIframe(), []) - const handleRefresh = async () => { + const handleRefresh = async (): Promise => { // Get current calendar range if (calendarRef.current) { const view = calendarRef.current.view @@ -69,7 +64,7 @@ export default function CalendarLayout() { } } - const handleDateChange = (date: Date) => { + const handleDateChange = (date: Date): void => { setCurrentDate(date) } @@ -96,14 +91,14 @@ export default function CalendarLayout() { } // Cleanup on unmount - return () => { + return (): void => { document.body.classList.remove('fullscreen-view') } }, [view]) const menubarProps: MenubarProps = { calendarRef, - onRefresh: handleRefresh, + onRefresh: () => void handleRefresh(), currentDate, onDateChange: handleDateChange, currentView, diff --git a/src/components/Calendar/CustomCalendar.styl b/src/components/Calendar/CustomCalendar.styl index 5ea5c02..c5464ef 100644 --- a/src/components/Calendar/CustomCalendar.styl +++ b/src/components/Calendar/CustomCalendar.styl @@ -32,7 +32,6 @@ a.fc-timegrid-axis-cushion .fc-daygrid-day-top .fc-daygrid-day-number, span.fc-daygrid-day-number color #243b55 - margin 0 6px 0 0 font-family Roboto font-size 28px font-style normal @@ -75,6 +74,15 @@ span.fc-daygrid-day-number.current-date span.fc-daygrid-day-number.current-date:after background-color #FB9E3A +.fc-daygrid-day-top--mobile .fc-daygrid-day-number + font-size 22px + width 38px + line-height 29px + +.fc-daygrid-day-top--mobile .fc-daygrid-day-number:after + width 36px + height 36px + td.fc-timegrid-slot.fc-timegrid-slot-label.fc-scrollgrid-shrink, td.fc-timegrid-slot.fc-timegrid-slot-label.fc-timegrid-slot-minor border 0 diff --git a/src/components/Calendar/Sidebar/SideBar.tsx b/src/components/Calendar/Sidebar/SideBar.tsx index 4db5aea..76a3d47 100644 --- a/src/components/Calendar/Sidebar/SideBar.tsx +++ b/src/components/Calendar/Sidebar/SideBar.tsx @@ -21,8 +21,12 @@ export interface CalendarSidebarProps { currentView: string } -export default function Sidebar(sharedProps: CalendarSidebarProps) { - const { isTablet } = useScreenSizeDetection() +const Sidebar: React.FC = ( + sharedProps: CalendarSidebarProps +) => { + const { isTablet, isTooSmall: isMobile } = useScreenSizeDetection() + + if (isMobile) return null return isTablet ? ( @@ -30,3 +34,5 @@ export default function Sidebar(sharedProps: CalendarSidebarProps) { ) } + +export default Sidebar diff --git a/src/components/Menubar/DesktopMenubar.tsx b/src/components/Menubar/DesktopMenubar.tsx index 4c11f73..4cdf120 100644 --- a/src/components/Menubar/DesktopMenubar.tsx +++ b/src/components/Menubar/DesktopMenubar.tsx @@ -6,11 +6,11 @@ import { AppListMenu } from './AppListMenu' import SearchBar from './EventSearchBar' import { MainTitle } from './MainTitle' import { SharedMenubarProps } from './Menubar' -import { NavigationControls } from './NavigationControls' +import { NavigationControls } from './components/NavigationControls' import { SelectView } from './SelectView' import { UserMenu } from './UserMenu' -export function DesktopMenubar({ +export const DesktopMenubar: React.FC = ({ calendarRef, currentView, isIframe, @@ -29,7 +29,7 @@ export function DesktopMenubar({ user, userMenuAnchorEl, onUserMenuClose -}: SharedMenubarProps) { +}) => { const { t } = useI18n() return ( diff --git a/src/components/Menubar/Menubar.styl b/src/components/Menubar/Menubar.styl index a3122af..3cdf3f7 100644 --- a/src/components/Menubar/Menubar.styl +++ b/src/components/Menubar/Menubar.styl @@ -39,6 +39,7 @@ width 100% z-index 1100 height 60px + min-height 60px background-color #fff .menubar-item diff --git a/src/components/Menubar/Menubar.tsx b/src/components/Menubar/Menubar.tsx index 599b964..f0e2f1d 100644 --- a/src/components/Menubar/Menubar.tsx +++ b/src/components/Menubar/Menubar.tsx @@ -11,6 +11,7 @@ import { useI18n } from 'twake-i18n' import { DesktopMenubar } from './DesktopMenubar' import './Menubar.styl' import { TabletMenubar } from './TabletMenubar' +import { MobileMenubar } from './MobileMenuBar' export type AppIconProps = { name: string @@ -64,7 +65,7 @@ export const Menubar: React.FC = ({ null ) const dispatch = useAppDispatch() - const { isTablet } = useScreenSizeDetection() + const { isTablet, isTooSmall: isMobile } = useScreenSizeDetection() useEffect(() => { const resetMenuAnchorsOnResize = (): void => { @@ -84,9 +85,9 @@ export const Menubar: React.FC = ({ return null } - const handleNavigation = async (action: 'prev' | 'next' | 'today') => { + const handleNavigation = (action: 'prev' | 'next' | 'today'): void => { if (!calendarRef.current) return - await dispatch(setView('calendar')) + dispatch(setView('calendar')) switch (action) { case 'prev': calendarRef.current.prev() @@ -106,31 +107,31 @@ export const Menubar: React.FC = ({ } } - const handleViewChange = async (view: string) => { + const handleViewChange = (view: string): void => { // Notify parent about view change if (onViewChange) { onViewChange(view) } } - const handleAppMenuOpen = (event: React.MouseEvent) => + const handleAppMenuOpen = (event: React.MouseEvent): void => setAnchorEl(event.currentTarget) - const handleAppMenuClose = () => setAnchorEl(null) + const handleAppMenuClose = (): void => setAnchorEl(null) - const handleUserMenuOpen = (event: React.MouseEvent) => + const handleUserMenuOpen = (event: React.MouseEvent): void => setUserMenuAnchorEl(event.currentTarget) - const handleUserMenuClose = () => { + const handleUserMenuClose = (): void => { setUserMenuAnchorEl(null) } - const handleSettingsClick = () => { + const handleSettingsClick = (): void => { dispatch(setView('settings')) handleUserMenuClose() } - const handleLogoutClick = async () => { + const handleLogoutClick = async (): Promise => { const logoutUrl = await Logout() sessionStorage.removeItem('tokenSet') redirectTo(logoutUrl.href) @@ -161,11 +162,22 @@ export const Menubar: React.FC = ({ onUserMenuOpen: handleUserMenuOpen, onUserMenuClose: handleUserMenuClose, onSettingsClick: handleSettingsClick, - onLogoutClick: handleLogoutClick, + onLogoutClick: () => void handleLogoutClick(), onNavigate: handleNavigation, user } + if (isMobile) { + return ( + + ) + } + return isTablet ? ( ) : ( diff --git a/src/components/Menubar/MobileMenuBar.tsx b/src/components/Menubar/MobileMenuBar.tsx new file mode 100644 index 0000000..f311c40 --- /dev/null +++ b/src/components/Menubar/MobileMenuBar.tsx @@ -0,0 +1,87 @@ +import { CalendarApi } from '@fullcalendar/core' +import { IconButton, Stack } from '@linagora/twake-mui' +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown' +import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp' +import React, { useState } from 'react' +import { useI18n } from 'twake-i18n' +import SearchBar from './EventSearchBar' +import './Menubar.styl' +import { DatePickerMobile } from './components/DatePickerMobile' +import { Typography } from '@mui/material' +import { SmallNavigationControls } from './components/SmallNavigationControls' + +export type MobileMenubarProps = { + calendarRef: React.RefObject + currentDate: Date + onDateChange?: (date: Date) => void + handleNavigation: (action: 'prev' | 'next' | 'today') => void +} + +export const MobileMenubar: React.FC = ({ + calendarRef, + currentDate, + onDateChange, + handleNavigation +}) => { + const { t } = useI18n() + + const [openDatePicker, setOpenDatePicker] = useState(false) + + // Use i18n for month names instead of date-fns + const monthIndex = currentDate.getMonth() + const year = currentDate.getFullYear() + const monthName = t(`months.standalone.${monthIndex}`) + const dateLabel = `${monthName} ${year}` + + return ( + <> +
+
+
+ +
+
+ + setOpenDatePicker(prev => !prev)} + > + {dateLabel} + + setOpenDatePicker(prev => !prev)} + aria-label={ + openDatePicker + ? t('menubar.hideDatePicker') + : t('menubar.showDatePicker') + } + title={ + openDatePicker + ? t('menubar.hideDatePicker') + : t('menubar.showDatePicker') + } + aria-expanded={openDatePicker} + > + {openDatePicker ? : } + + +
+
+
+
+ +
+
+
+ + {openDatePicker ? ( + + ) : null} + + ) +} diff --git a/src/components/Menubar/TabletMenubar.tsx b/src/components/Menubar/TabletMenubar.tsx index cd763b2..9eb51da 100644 --- a/src/components/Menubar/TabletMenubar.tsx +++ b/src/components/Menubar/TabletMenubar.tsx @@ -1,16 +1,14 @@ -import { IconButton, Stack } from '@linagora/twake-mui' -import ChevronLeftIcon from '@mui/icons-material/ChevronLeft' -import ChevronRightIcon from '@mui/icons-material/ChevronRight' +import { IconButton } from '@linagora/twake-mui' import MenuIcon from '@mui/icons-material/Menu' import RefreshIcon from '@mui/icons-material/Refresh' -import TodayIcon from '@mui/icons-material/Today' import { useI18n } from 'twake-i18n' import SearchBar from './EventSearchBar' import { MainTitle } from './MainTitle' import { SharedMenubarProps } from './Menubar' import { UserMenu } from './UserMenu' +import { SmallNavigationControls } from './components/SmallNavigationControls' -export function TabletMenubar({ +export const TabletMenubar: React.FC = ({ calendarRef, currentView, isIframe, @@ -26,7 +24,7 @@ export function TabletMenubar({ onUserMenuClose, onViewChange, onDateChange -}: SharedMenubarProps) { +}: SharedMenubarProps) => { const { t } = useI18n() return ( @@ -52,36 +50,7 @@ export function TabletMenubar({ )}
-
- - onNavigate('prev')} - aria-label={t('menubar.prev')} - title={t('menubar.prev')} - > - - - onNavigate('today')} - aria-label={t('menubar.today')} - title={t('menubar.today')} - > - - - onNavigate('next')} - aria-label={t('menubar.next')} - title={t('menubar.next')} - > - - - -
+
diff --git a/src/components/Menubar/components/DatePickerMobile.tsx b/src/components/Menubar/components/DatePickerMobile.tsx new file mode 100644 index 0000000..04a95ef --- /dev/null +++ b/src/components/Menubar/components/DatePickerMobile.tsx @@ -0,0 +1,93 @@ +import { CalendarApi } from '@fullcalendar/core' +import { useTheme } from '@linagora/twake-mui' +import { StaticDatePicker } from '@mui/x-date-pickers/StaticDatePicker' +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' +import React from 'react' +import { useI18n } from 'twake-i18n' +import dayjs from 'dayjs' +import { PickerValue } from '@mui/x-date-pickers/internals' +import { MonthSelector } from './MonthSelector' + +export type DatePickerMobileProps = { + calendarRef: React.RefObject + currentDate: Date + onDateChange?: (date: Date) => void +} + +export const DatePickerMobile: React.FC = ({ + calendarRef, + currentDate, + onDateChange +}) => { + const { t, lang } = useI18n() + const theme = useTheme() + + const onChangeDate = (newDate: PickerValue): void => { + if (newDate && calendarRef.current) { + const d = newDate.toDate() + calendarRef.current.gotoDate(d) + onDateChange?.(d) + } + } + + const onMonthChange = (monthIndex: number): void => { + if (!calendarRef.current) return + const currentCalendarDate = calendarRef.current.getDate() + const year = currentCalendarDate.getFullYear() + const lastDayOfMonth = new Date(year, monthIndex + 1, 0).getDate() + const clampedDay = Math.min(currentCalendarDate.getDate(), lastDayOfMonth) + onChangeDate(dayjs(new Date(year, monthIndex, clampedDay))) + } + + return ( + + null, actionBar: () => null }} + sx={{ width: '100%', marginTop: '10px' }} + slotProps={{ + toolbar: { hidden: true }, + layout: { + sx: { + '.MuiDateCalendar-root': { + width: '100%', + margin: 0, + maxWidth: 'unset', + maxHeight: 'unset', + height: '400px' + }, + '.MuiDayCalendar-header': { + width: '100%', + justifyContent: 'space-around' + }, + '.MuiDayCalendar-weekContainer': { + width: '100%', + justifyContent: 'space-around' + } + } + }, + day: { + sx: { + '&.MuiPickersDay-dayOutsideMonth': { + color: theme.palette.grey[500] + } + } + } + }} + /> + + + ) +} diff --git a/src/components/Menubar/components/MonthSelector.tsx b/src/components/Menubar/components/MonthSelector.tsx new file mode 100644 index 0000000..d20a9cd --- /dev/null +++ b/src/components/Menubar/components/MonthSelector.tsx @@ -0,0 +1,63 @@ +import { Box, Button, radius } from '@linagora/twake-mui' +import React, { useEffect, useRef } from 'react' +import { useI18n } from 'twake-i18n' + +export type MonthSelectorProps = { + currentDate: Date + onMonthChange: (monthIndex: number) => void +} + +export const MonthSelector: React.FC = ({ + currentDate, + onMonthChange +}) => { + const { t } = useI18n() + const monthScrollRef = useRef(null) + + useEffect(() => { + const container = monthScrollRef.current + if (!container) return + const selectedIndex = currentDate.getMonth() + const button = container.children[selectedIndex] as HTMLElement | undefined + if (button) { + button.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center' + }) + } + }, [currentDate]) + + return ( + + {Array.from({ length: 12 }).map((_, i) => { + const isSelected = currentDate.getMonth() === i + return ( + + ) + })} + + ) +} diff --git a/src/components/Menubar/NavigationControls.tsx b/src/components/Menubar/components/NavigationControls.tsx similarity index 94% rename from src/components/Menubar/NavigationControls.tsx rename to src/components/Menubar/components/NavigationControls.tsx index f365d5d..0c1c964 100644 --- a/src/components/Menubar/NavigationControls.tsx +++ b/src/components/Menubar/components/NavigationControls.tsx @@ -3,11 +3,9 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft' import ChevronRightIcon from '@mui/icons-material/ChevronRight' import { useI18n } from 'twake-i18n' -export function NavigationControls({ - onNavigate -}: { +export const NavigationControls: React.FC<{ onNavigate: (action: 'today' | 'next' | 'prev') => void -}) { +}> = ({ onNavigate }) => { const { t } = useI18n() return (
diff --git a/src/components/Menubar/components/SmallNavigationControls.tsx b/src/components/Menubar/components/SmallNavigationControls.tsx new file mode 100644 index 0000000..e33c64a --- /dev/null +++ b/src/components/Menubar/components/SmallNavigationControls.tsx @@ -0,0 +1,44 @@ +import { IconButton, Stack } from '@linagora/twake-mui' +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft' +import ChevronRightIcon from '@mui/icons-material/ChevronRight' +import TodayIcon from '@mui/icons-material/Today' +import { useI18n } from 'twake-i18n' + +export const SmallNavigationControls: React.FC<{ + onNavigate: (action: 'today' | 'next' | 'prev') => void +}> = ({ onNavigate }) => { + const { t } = useI18n() + + return ( +
+ + onNavigate('prev')} + aria-label={t('menubar.prev')} + title={t('menubar.prev')} + > + + + onNavigate('today')} + > + + + onNavigate('next')} + aria-label={t('menubar.next')} + title={t('menubar.next')} + > + + + +
+ ) +} diff --git a/src/declarations.ts b/src/declarations.ts new file mode 100644 index 0000000..c7f957c --- /dev/null +++ b/src/declarations.ts @@ -0,0 +1,20 @@ +declare module '@linagora/twake-mui' { + export * from '@mui/material' + import type { AvatarProps as MuiAvatarProps } from '@mui/material' + + export type AvatarSize = 'xs' | 's' | 'm' | 'l' | 'xl' + export type AvatarDisplay = 'initial' | 'inline' + + export interface AvatarProps extends Omit { + color?: string + size?: AvatarSize | number + border?: boolean + innerBorder?: boolean + disabled?: boolean + display?: AvatarDisplay + } + + export const Avatar: React.FC + + export const radius: Record +} diff --git a/src/locales/en.json b/src/locales/en.json index 8bd0af7..9b92255 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -247,7 +247,9 @@ "logoAlt": "Calendar", "settings": "Settings", "help": "Help", - "logout": "Logout" + "logout": "Logout", + "hideDatePicker": "Hide date picker", + "showDatePicker": "Show date picker" }, "settings": { "title": "Settings", @@ -365,6 +367,20 @@ "9": "October", "10": "November", "11": "December" + }, + "short": { + "0": "Jan", + "1": "Feb", + "2": "Mar", + "3": "Apr", + "4": "May", + "5": "Jun", + "6": "Jul", + "7": "Aug", + "8": "Sep", + "9": "Oct", + "10": "Nov", + "11": "Dec" } }, "mobile": { diff --git a/src/locales/fr.json b/src/locales/fr.json index 359411b..eb9b4f6 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -248,7 +248,9 @@ "settings": "Paramètres", "help": "Aide", "logout": "Déconnexion", - "toggleSidebar": "Afficher/masquer la barre latérale" + "toggleSidebar": "Afficher/masquer la barre latérale", + "hideDatePicker": "Masquer le sélecteur de date", + "showDatePicker": "Afficher le sélecteur de date" }, "settings": { "title": "Paramètres", @@ -366,6 +368,20 @@ "9": "Octobre", "10": "Novembre", "11": "Décembre" + }, + "short": { + "0": "Jan", + "1": "Fév", + "2": "Mar", + "3": "Avr", + "4": "Mai", + "5": "Juin", + "6": "Juil", + "7": "Aoû", + "8": "Sep", + "9": "Oct", + "10": "Nov", + "11": "Déc" } }, "mobile": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 9bdf33e..477a533 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -248,7 +248,9 @@ "settings": "Настройки", "help": "Помощь", "logout": "Выйти", - "toggleSidebar": "Показать/скрыть боковую панель" + "toggleSidebar": "Показать/скрыть боковую панель", + "hideDatePicker": "Скрыть выбор даты", + "showDatePicker": "Показать выбор даты" }, "settings": { "title": "Настройки", @@ -366,6 +368,20 @@ "9": "Октябрь", "10": "Ноябрь", "11": "Декабрь" + }, + "short": { + "0": "Янв", + "1": "Фев", + "2": "Мар", + "3": "Апр", + "4": "Май", + "5": "Июн", + "6": "Июл", + "7": "Авг", + "8": "Сен", + "9": "Окт", + "10": "Ноя", + "11": "Дек" } }, "mobile": { diff --git a/src/locales/vi.json b/src/locales/vi.json index 5ca45e9..f76191c 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -246,7 +246,9 @@ "settings": "Cài đặt", "help": "Giúp đỡ", "logout": "Đăng xuất", - "toggleSidebar": "Ẩn/hiện thanh bên" + "toggleSidebar": "Ẩn/hiện thanh bên", + "hideDatePicker": "Ẩn chọn ngày", + "showDatePicker": "Hiện chọn ngày" }, "settings": { "title": "Cài đặt", @@ -364,6 +366,20 @@ "9": "Tháng 10", "10": "Tháng 11", "11": "Tháng 12" + }, + "short": { + "0": "Th1", + "1": "Th2", + "2": "Th3", + "3": "Th4", + "4": "Th5", + "5": "Th6", + "6": "Th7", + "7": "Th8", + "8": "Th9", + "9": "Th10", + "10": "Th11", + "11": "Th12" } }, "mobile": {