feat: add visual time picker to DateTimePicker (#15057) (#17952)

## Summary
Added a visual time picker dropdown for DateTime fields, replacing the
previous text input. Users can now select hours and minutes through an
intuitive scrollable interface. (Fixes #15057

 ## Changes
- **New component**: Add a `TimePickerDropdown` - Visual picker with
scrollable hour/minute columns
- **Updated**: `DateTimePickerHeader` - Implemented time picker dropdown
in `DateTimePickerHeader` and Move month/year picker to right side
  ## Snapshots
  
<img width="493" height="421" alt="image"
src="https://github.com/user-attachments/assets/3bd1f0a0-0ac2-473d-935e-d9f28b0e40e2"
/>


https://github.com/user-attachments/assets/daa5cba5-c86c-46aa-a634-0f5c04523af1



**If there is no enough place at right, auto-move month/year selector to
the left side**

Hi, @Bonapara I followed the Figma you shared to complete this feature.
Could you please review it for me? Thanks a lot.

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
BugIsGod
2026-03-10 13:39:46 +00:00
committed by GitHub
parent dee55b635f
commit 033d297695
17 changed files with 731 additions and 196 deletions
@@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from 'react';
import { useRegisterInputEvents } from '@/object-record/record-field/ui/meta-types/input/hooks/useRegisterInputEvents';
import {
DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID,
DateTimePicker,
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
@@ -65,10 +66,12 @@ export const DateTimeInput = ({
const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown();
const { closeDropdown: closeDropdownYearSelect } = useCloseDropdown();
const { closeDropdown: closeMonthYearPanel } = useCloseDropdown();
const handleEnter = () => {
closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID);
closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID);
closeMonthYearPanel(DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID);
onEnter(internalValue);
};
@@ -76,6 +79,7 @@ export const DateTimeInput = ({
const handleEscape = () => {
closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID);
closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID);
closeMonthYearPanel(DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID);
onEscape(internalValue);
};
@@ -87,6 +91,7 @@ export const DateTimeInput = ({
if (currentFocusId === instanceId) {
closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID);
closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID);
closeMonthYearPanel(DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID);
onClickOutside(event, internalValue);
}
},
@@ -94,6 +99,7 @@ export const DateTimeInput = ({
instanceId,
closeDropdownYearSelect,
closeDropdownMonthSelect,
closeMonthYearPanel,
onClickOutside,
internalValue,
store,
@@ -7,11 +7,6 @@ import { getMonthSelectOptions } from '@/ui/input/components/internal/date/utils
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
import { IconChevronLeft, IconChevronRight } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import {
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
} from './DateTimePicker';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { Temporal } from 'temporal-polyfill';
@@ -19,6 +14,15 @@ import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID =
'date-picker-month-and-year-dropdown-month-select';
const MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID =
'date-picker-month-and-year-dropdown-year-select';
const YEARS_SELECT_OPTIONS = Array.from(
{ length: 200 },
(_, i) => new Date().getFullYear() + 50 - i,
).map((year) => ({ label: year.toString(), value: year }));
const StyledCustomDatePickerHeader = styled.div`
align-items: center;
display: flex;
@@ -30,11 +34,6 @@ const StyledCustomDatePickerHeader = styled.div`
padding-top: ${themeCssVariables.spacing[2]};
`;
const years = Array.from(
{ length: 200 },
(_, i) => new Date().getFullYear() + 50 - i,
).map((year) => ({ label: year.toString(), value: year }));
type DatePickerHeaderProps = {
date: string | null;
onChange?: (date: string | null) => void;
@@ -89,7 +88,7 @@ export const DatePickerHeader = ({
dropdownId={MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID}
onChange={onChangeYear}
value={dateParsed?.year}
options={years}
options={YEARS_SELECT_OPTIONS}
fullWidth
/>
</ClickOutsideListenerContext.Provider>
@@ -5,7 +5,10 @@ import {
type RelativeDateFilter,
} from 'twenty-shared/utils';
import { DateTimePickerHeader } from '@/ui/input/components/internal/date/components/DateTimePickerHeader';
import {
DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID,
DateTimePickerHeader,
} from '@/ui/input/components/internal/date/components/DateTimePickerHeader';
import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader';
import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
@@ -24,11 +27,22 @@ import { useGetShiftedDateToSystemTimeZone } from '@/ui/input/components/interna
import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { Temporal } from 'temporal-polyfill';
export {
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
} from '@/ui/input/components/internal/date/components/DatePicker';
export { DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID } from '@/ui/input/components/internal/date/components/DateTimePickerHeader';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
export const MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID =
'date-picker-month-and-year-dropdown-month-select';
export const MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID =
'date-picker-month-and-year-dropdown-year-select';
const StyledOuterWrapper = styled.div`
align-items: flex-start;
display: flex;
flex-direction: row;
position: relative;
width: 280px;
`;
const StyledContainer = styled.div<{
calendarDisabled?: boolean;
@@ -361,6 +375,8 @@ export const DateTimePicker = ({
const dateToUse =
date ?? Temporal.Now.zonedDateTimeISO(timeZone ?? userTimezone);
const { closeDropdown: closeMonthYearPanel } = useCloseDropdown();
const { getShiftedDateToSystemTimeZone } =
useGetShiftedDateToSystemTimeZone();
@@ -381,21 +397,13 @@ export const DateTimePicker = ({
return { zonedDateTime };
};
const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown();
const { closeDropdown: closeDropdownYearSelect } = useCloseDropdown();
const handleClear = () => {
closeDropdowns();
closeMonthYearPanel(DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID);
onClear?.();
};
const closeDropdowns = () => {
closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID);
closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID);
};
const handleClose = (newDate: Temporal.ZonedDateTime) => {
closeDropdowns();
closeMonthYearPanel(DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID);
onClose?.(newDate);
};
@@ -472,84 +480,86 @@ export const DateTimePicker = ({
convertFirstDayOfTheWeekToCalendarStartDayNumber(userFirstDayOfTheWeek);
return (
<StyledContainer calendarDisabled={isRelative}>
<Suspense
fallback={
<StyledDatePickerFallback>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<Skeleton
width={200}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
/>
<Skeleton
width={240}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
/>
<Skeleton
width={220}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
/>
<Skeleton
width={180}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
</SkeletonTheme>
</StyledDatePickerFallback>
}
>
<ReactDatePicker
open={true}
selected={shiftedDateForReactDatePicker}
selectedDates={selectedDates}
openToDate={shiftedDateForReactDatePicker}
disabledKeyboardNavigation
onChange={handleDateChange}
calendarStartDay={calendarStartDayNumber}
renderCustomHeader={({
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) =>
isRelative ? (
<RelativeDatePickerHeader
instanceId={instanceId}
direction={relativeDate?.direction ?? 'PAST'}
amount={relativeDate?.amount}
unit={relativeDate?.unit ?? 'DAY'}
onChange={onRelativeDateChange}
allowIntraDayUnits={true}
/>
) : (
<DateTimePickerHeader
date={dateToUse}
onChange={onChange}
onChangeMonth={handleChangeMonth}
onChangeYear={handleChangeYear}
onAddMonth={handleAddMonth}
onSubtractMonth={handleSubtractMonth}
prevMonthButtonDisabled={prevMonthButtonDisabled}
nextMonthButtonDisabled={nextMonthButtonDisabled}
hideInput={hideHeaderInput}
/>
)
<StyledOuterWrapper>
<StyledContainer calendarDisabled={isRelative}>
<Suspense
fallback={
<StyledDatePickerFallback>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<Skeleton
width={200}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
/>
<Skeleton
width={240}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
/>
<Skeleton
width={220}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
/>
<Skeleton
width={180}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
</SkeletonTheme>
</StyledDatePickerFallback>
}
onSelect={handleDateSelect}
selectsMultiple={isRelative}
/>
</Suspense>
{clearable && (
<>
<StyledSeparator />
<StyledButtonContainer onClick={handleClear}>
<StyledButtonContent>
<MenuItemLeftContent LeftIcon={IconCalendarX} text={t`Clear`} />
</StyledButtonContent>
</StyledButtonContainer>
</>
)}
</StyledContainer>
>
<ReactDatePicker
open={true}
selected={shiftedDateForReactDatePicker}
selectedDates={selectedDates}
openToDate={shiftedDateForReactDatePicker}
disabledKeyboardNavigation
onChange={handleDateChange}
calendarStartDay={calendarStartDayNumber}
renderCustomHeader={({
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) =>
isRelative ? (
<RelativeDatePickerHeader
instanceId={instanceId}
direction={relativeDate?.direction ?? 'PAST'}
amount={relativeDate?.amount}
unit={relativeDate?.unit ?? 'DAY'}
onChange={onRelativeDateChange}
allowIntraDayUnits={true}
/>
) : (
<DateTimePickerHeader
date={dateToUse}
onChange={onChange}
onAddMonth={handleAddMonth}
onSubtractMonth={handleSubtractMonth}
prevMonthButtonDisabled={prevMonthButtonDisabled}
nextMonthButtonDisabled={nextMonthButtonDisabled}
hideInput={hideHeaderInput}
onChangeMonth={handleChangeMonth}
onChangeYear={handleChangeYear}
/>
)
}
onSelect={handleDateSelect}
selectsMultiple={isRelative}
/>
</Suspense>
{clearable && (
<>
<StyledSeparator />
<StyledButtonContainer onClick={handleClear}>
<StyledButtonContent>
<MenuItemLeftContent LeftIcon={IconCalendarX} text={t`Clear`} />
</StyledButtonContent>
</StyledButtonContainer>
</>
)}
</StyledContainer>
</StyledOuterWrapper>
);
};
@@ -1,70 +1,202 @@
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { useEffect } from 'react';
import { useIMask } from 'react-imask';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { Select } from '@/ui/input/components/Select';
import { DateTimePickerInput } from '@/ui/input/components/internal/date/components/DateTimePickerInput';
import { useTimeInput } from '@/ui/input/components/internal/date/hooks/useTimeInput';
import { getMonthSelectOptions } from '@/ui/input/components/internal/date/utils/getMonthSelectOptions';
import { getTimeBlocks } from '@/ui/input/components/internal/date/utils/getTimeBlocks';
import { getTimeMask } from '@/ui/input/components/internal/date/utils/getTimeMask';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
import { t } from '@lingui/core/macro';
import { type Temporal } from 'temporal-polyfill';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { IconChevronLeft, IconChevronRight } from 'twenty-ui/display';
import { isDefined } from 'twenty-shared/utils';
import {
IconCalendar,
IconChevronLeft,
IconChevronRight,
IconClock,
} from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
} from './DateTimePicker';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledCustomDatePickerHeader = styled.div`
export const DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID =
'date-time-picker-month-year-panel';
const YEARS_SELECT_OPTIONS = Array.from(
{ length: 200 },
(_, i) => new Date().getFullYear() + 50 - i,
).map((year) => ({ label: year.toString(), value: year }));
const StyledTimeRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
justify-content: flex-end;
justify-content: flex-start;
padding-bottom: ${themeCssVariables.spacing[2]};
padding-left: ${themeCssVariables.spacing[2]};
padding-right: ${themeCssVariables.spacing[2]};
padding-top: ${themeCssVariables.spacing[2]};
`;
const StyledTimeInputWrapper = styled.div`
flex-grow: 1;
`;
const StyledTimeInputContainer = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
box-sizing: border-box;
display: flex;
gap: ${themeCssVariables.spacing[1]};
height: ${themeCssVariables.spacing[8]};
padding: 0 ${themeCssVariables.spacing[2]};
transition: border-color 0.15s ease;
&:hover {
border-color: ${themeCssVariables.border.color.strong};
}
`;
const StyledClockIcon = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.tertiary};
display: flex;
flex-shrink: 0;
`;
const StyledTimeInput = styled.input`
background: transparent;
border: none;
color: ${themeCssVariables.font.color.primary};
flex: 1;
font-family: ${themeCssVariables.font.family};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.regular};
letter-spacing: 0.05em;
outline: none;
width: 100%;
&::placeholder {
color: ${themeCssVariables.font.color.light};
font-weight: ${themeCssVariables.font.weight.medium};
}
&:disabled {
color: ${themeCssVariables.font.color.tertiary};
}
`;
const StyledRightControls = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
`;
const StyledNavigationButtons = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[1]};
`;
const StyledSeparator = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
height: 1px;
width: 100%;
`;
const years = Array.from(
{ length: 200 },
(_, i) => new Date().getFullYear() + 50 - i,
).map((year) => ({ label: year.toString(), value: year }));
const StyledMonthYearSelector = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[1]};
width: 160px;
`;
type DateTimePickerHeaderProps = {
date: Temporal.ZonedDateTime | null;
onChange?: (date: Temporal.ZonedDateTime | null) => void;
onChangeMonth: (month: number) => void;
onChangeYear: (year: number) => void;
onAddMonth: () => void;
onSubtractMonth: () => void;
prevMonthButtonDisabled: boolean;
nextMonthButtonDisabled: boolean;
hideInput?: boolean;
onChangeMonth: (month: number) => void;
onChangeYear: (year: number) => void;
};
export const DateTimePickerHeader = ({
date,
onChange,
onChangeMonth,
onChangeYear,
onAddMonth,
onSubtractMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
hideInput = false,
onChangeMonth,
onChangeYear,
}: DateTimePickerHeaderProps) => {
const { timeFormat } = useDateTimeFormat();
const { formatTime, parseTime, isHour12 } = useTimeInput(timeFormat);
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const userLocale = currentWorkspaceMember?.locale ?? SOURCE_LOCALE;
const { closeDropdown: closeMonthSelect } = useCloseDropdown();
const { closeDropdown: closeYearSelect } = useCloseDropdown();
const closeInnerDropdowns = () => {
closeMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID);
closeYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID);
};
const { ref: iMaskRef, setValue } = useIMask(
{
mask: getTimeMask(timeFormat),
blocks: getTimeBlocks(timeFormat),
lazy: false,
autofix: true,
},
{
defaultValue: isDefined(date)
? formatTime(date.hour, date.minute)
: undefined,
onComplete: (value) => {
if (!date) return;
const parsedTime = parseTime(value);
if (!parsedTime) {
return;
}
onChange?.(
date.with({ hour: parsedTime.hour, minute: parsedTime.minute }),
);
},
},
);
useEffect(() => {
if (isDefined(date)) {
setValue(formatTime(date.hour, date.minute));
}
}, [date, formatTime, setValue]);
const timeInputRef = iMaskRef as React.Ref<HTMLInputElement>;
return (
<>
{!hideInput && (
@@ -73,46 +205,87 @@ export const DateTimePickerHeader = ({
<StyledSeparator />
</>
)}
<StyledCustomDatePickerHeader>
<ClickOutsideListenerContext.Provider
value={{
excludedClickOutsideId: MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
}}
>
<Select
dropdownId={MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID}
options={getMonthSelectOptions(userLocale)}
onChange={onChangeMonth}
value={date?.month ?? undefined}
fullWidth
<StyledTimeRow>
<StyledTimeInputWrapper>
<StyledTimeInputContainer>
<StyledClockIcon>
<IconClock size={16} />
</StyledClockIcon>
<StyledTimeInput
type="text"
ref={timeInputRef}
placeholder={isHour12 ? 'HH:mm AA' : 'HH:mm'}
/>
</StyledTimeInputContainer>
</StyledTimeInputWrapper>
<StyledRightControls>
<Dropdown
dropdownId={DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID}
clickableComponent={
<LightIconButton
Icon={IconCalendar}
size="medium"
aria-label={t`Select month and year`}
/>
}
dropdownPlacement="bottom-start"
dropdownOffset={{ y: 8 }}
onClose={closeInnerDropdowns}
excludedClickOutsideIds={[
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
]}
dropdownComponents={
<StyledMonthYearSelector>
<ClickOutsideListenerContext.Provider
value={{
excludedClickOutsideId:
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
}}
>
<Select
dropdownId={MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID}
options={getMonthSelectOptions(userLocale)}
onChange={onChangeMonth}
value={date?.month}
fullWidth={false}
dropdownWidth={160}
/>
</ClickOutsideListenerContext.Provider>
<ClickOutsideListenerContext.Provider
value={{
excludedClickOutsideId:
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
}}
>
<Select
dropdownId={MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID}
onChange={onChangeYear}
value={date?.year}
options={YEARS_SELECT_OPTIONS}
fullWidth={false}
dropdownWidth={160}
/>
</ClickOutsideListenerContext.Provider>
</StyledMonthYearSelector>
}
/>
</ClickOutsideListenerContext.Provider>
<ClickOutsideListenerContext.Provider
value={{
excludedClickOutsideId: MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
}}
>
<Select
dropdownId={MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID}
onChange={onChangeYear}
value={date?.year}
options={years}
fullWidth
/>
</ClickOutsideListenerContext.Provider>
<LightIconButton
Icon={IconChevronLeft}
onClick={onSubtractMonth}
size="medium"
disabled={prevMonthButtonDisabled}
/>
<LightIconButton
Icon={IconChevronRight}
onClick={onAddMonth}
size="medium"
disabled={nextMonthButtonDisabled}
/>
</StyledCustomDatePickerHeader>
<StyledNavigationButtons>
<LightIconButton
Icon={IconChevronLeft}
onClick={onSubtractMonth}
size="medium"
disabled={prevMonthButtonDisabled}
/>
<LightIconButton
Icon={IconChevronRight}
onClick={onAddMonth}
size="medium"
disabled={nextMonthButtonDisabled}
/>
</StyledNavigationButtons>
</StyledRightControls>
</StyledTimeRow>
</>
);
};
@@ -38,13 +38,20 @@ export const WithOpenMonthSelect: Story = {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
// Increased timeout to account for lazy-loaded react-datepicker on slower CI runners
const monthSelect = await canvas.findByText(
// Wait for date picker to load and click calendar icon to open month/year selector
const calendarButton = await canvas.findByRole(
'button',
{ name: 'Select month and year' },
{ timeout: 10000 },
);
await userEvent.click(calendarButton);
// Now find and click the month select
const monthSelect = await body.findByText(
'January',
{},
{ timeout: 10000 },
);
await userEvent.click(monthSelect);
for (const monthLabel of [
@@ -65,7 +72,7 @@ export const WithOpenMonthSelect: Story = {
await userEvent.click(await body.findByText('February'));
expect(await canvas.findByText('February')).toBeInTheDocument();
expect(await body.findByText('February')).toBeInTheDocument();
},
};
@@ -74,9 +81,15 @@ export const WithOpenYearSelect: Story = {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
// Increased timeout to account for lazy-loaded react-datepicker on slower CI runners
const yearSelect = await canvas.findByText('2023', {}, { timeout: 10000 });
// Wait for date picker to load and click calendar icon to open month/year selector
const calendarButton = await canvas.findByRole(
'button',
{ name: 'Select month and year' },
{ timeout: 10000 },
);
await userEvent.click(calendarButton);
const yearSelect = await body.findByText('2023', {}, { timeout: 10000 });
await userEvent.click(yearSelect);
for (const yearLabel of ['2024', '2025', '2026']) {
@@ -85,6 +98,21 @@ export const WithOpenYearSelect: Story = {
await userEvent.click(await body.findByText('2024'));
expect(await canvas.findByText('2024')).toBeInTheDocument();
expect(await body.findByText('2024')).toBeInTheDocument();
},
};
export const WithTimeInput: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Increased timeout to account for lazy-loaded react-datepicker on slower CI runners
const timeInput = await canvas.findByPlaceholderText(
/HH:mm/,
{},
{ timeout: 10000 },
);
expect(timeInput).toBeInTheDocument();
},
};
@@ -0,0 +1,129 @@
import { renderHook } from '@testing-library/react';
import { TimeFormat } from '@/localization/constants/TimeFormat';
import { useTimeInput } from '@/ui/input/components/internal/date/hooks/useTimeInput';
describe('useTimeInput', () => {
describe('24-hour format', () => {
const render24 = () =>
renderHook(() => useTimeInput(TimeFormat.HOUR_24)).result.current;
describe('formatTime', () => {
it('should format with zero-padded hours and minutes', () => {
const { formatTime } = render24();
expect(formatTime(9, 5)).toBe('09:05');
});
it('should format midnight as 00:00', () => {
const { formatTime } = render24();
expect(formatTime(0, 0)).toBe('00:00');
});
it('should format 23:59', () => {
const { formatTime } = render24();
expect(formatTime(23, 59)).toBe('23:59');
});
});
describe('parseTime', () => {
it('should parse valid 24h time', () => {
const { parseTime } = render24();
expect(parseTime('14:30')).toEqual({ hour: 14, minute: 30 });
});
it('should parse midnight', () => {
const { parseTime } = render24();
expect(parseTime('00:00')).toEqual({ hour: 0, minute: 0 });
});
it('should return null for invalid format', () => {
const { parseTime } = render24();
expect(parseTime('invalid')).toBeNull();
});
it('should return null for out-of-range hour', () => {
const { parseTime } = render24();
expect(parseTime('25:00')).toBeNull();
});
it('should return null for out-of-range minute', () => {
const { parseTime } = render24();
expect(parseTime('12:60')).toBeNull();
});
});
it('should set isHour12 to false', () => {
const { isHour12 } = render24();
expect(isHour12).toBe(false);
});
});
describe('12-hour format', () => {
const render12 = () =>
renderHook(() => useTimeInput(TimeFormat.HOUR_12)).result.current;
describe('formatTime', () => {
it('should format AM time', () => {
const { formatTime } = render12();
expect(formatTime(9, 5)).toBe('09:05 AM');
});
it('should format PM time', () => {
const { formatTime } = render12();
expect(formatTime(14, 30)).toBe('02:30 PM');
});
it('should format midnight as 12:00 AM', () => {
const { formatTime } = render12();
expect(formatTime(0, 0)).toBe('12:00 AM');
});
it('should format noon as 12:00 PM', () => {
const { formatTime } = render12();
expect(formatTime(12, 0)).toBe('12:00 PM');
});
});
describe('parseTime', () => {
it('should parse AM time', () => {
const { parseTime } = render12();
expect(parseTime('09:30 AM')).toEqual({ hour: 9, minute: 30 });
});
it('should parse PM time', () => {
const { parseTime } = render12();
expect(parseTime('02:30 PM')).toEqual({ hour: 14, minute: 30 });
});
it('should parse 12:00 AM as midnight', () => {
const { parseTime } = render12();
expect(parseTime('12:00 AM')).toEqual({ hour: 0, minute: 0 });
});
it('should parse 12:00 PM as noon', () => {
const { parseTime } = render12();
expect(parseTime('12:00 PM')).toEqual({ hour: 12, minute: 0 });
});
it('should return null for invalid format', () => {
const { parseTime } = render12();
expect(parseTime('14:30')).toBeNull();
});
it('should return null for out-of-range hour', () => {
const { parseTime } = render12();
expect(parseTime('13:00 AM')).toBeNull();
});
it('should return null for out-of-range minute', () => {
const { parseTime } = render12();
expect(parseTime('12:60 PM')).toBeNull();
});
});
it('should set isHour12 to true', () => {
const { isHour12 } = render12();
expect(isHour12).toBe(true);
});
});
});
@@ -0,0 +1,75 @@
import { TimeFormat } from '@/localization/constants/TimeFormat';
import { useCallback } from 'react';
type TimeValue = {
hour: number;
minute: number;
};
type UseTimeInputReturn = {
formatTime: (hour: number, minute: number) => string;
parseTime: (timeString: string) => TimeValue | null;
isHour12: boolean;
};
export const useTimeInput = (timeFormat: TimeFormat): UseTimeInputReturn => {
const isHour12 = timeFormat === TimeFormat.HOUR_12;
const formatTime = useCallback(
(hour: number, minute: number): string => {
const hh = isHour12
? (hour % 12 || 12).toString().padStart(2, '0')
: hour.toString().padStart(2, '0');
const mm = minute.toString().padStart(2, '0');
if (isHour12) {
const amPm = hour >= 12 ? 'PM' : 'AM';
return `${hh}:${mm} ${amPm}`;
}
return `${hh}:${mm}`;
},
[isHour12],
);
const parseTime = (timeString: string): TimeValue | null => {
if (isHour12) {
const match = timeString.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
if (!match) return null;
const [, hoursStr, minutesStr, amPmStr] = match;
const hours = parseInt(hoursStr, 10);
const minutes = parseInt(minutesStr, 10);
const isValidHour = hours >= 1 && hours <= 12;
const isValidMinute = minutes >= 0 && minutes <= 59;
if (isNaN(hours) || isNaN(minutes) || !isValidHour || !isValidMinute) {
return null;
}
const isPM = amPmStr.toUpperCase() === 'PM';
const hour24 = (hours % 12) + (isPM ? 12 : 0);
return { hour: hour24, minute: minutes };
} else {
const match = timeString.match(/^(\d{1,2}):(\d{2})$/);
if (!match) return null;
const [, hoursStr, minutesStr] = match;
const hours = parseInt(hoursStr, 10);
const minutes = parseInt(minutesStr, 10);
const isValidHour = hours >= 0 && hours <= 23;
const isValidMinute = minutes >= 0 && minutes <= 59;
if (isNaN(hours) || isNaN(minutes) || !isValidHour || !isValidMinute) {
return null;
}
return { hour: hours, minute: minutes };
}
};
return {
formatTime,
parseTime,
isHour12,
};
};
@@ -18,7 +18,9 @@ export const getTimeBlocks = (timeFormat: TimeFormat) => {
maxLength: 2,
},
aa: {
mask: '**',
mask: IMask.MaskedEnum,
enum: ['AM', 'PM'],
prepareChar: (str: string) => str.toUpperCase(),
},
};
};
@@ -5,11 +5,16 @@ import { act } from 'react';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseAnyOpenDropdown } from '@/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown';
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { previousDropdownFocusIdStackState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdStackState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
const dropdownId = 'test-dropdown-id';
const dropdownIdA = 'test-dropdown-id-a';
const dropdownIdB = 'test-dropdown-id-b';
const dropdownIdC = 'test-dropdown-id-c';
const Wrapper = ({ children }: { children: React.ReactNode }) => {
return (
@@ -29,6 +34,8 @@ describe('useCloseAnyOpenDropdown', () => {
isDropdownOpenComponentState.atomFamily({ instanceId: dropdownId }),
false,
);
jotaiStore.set(activeDropdownFocusIdState.atom, null);
jotaiStore.set(previousDropdownFocusIdStackState.atom, []);
});
it('should open dropdown and then close it with closeAnyOpenDropdown', async () => {
@@ -64,4 +71,63 @@ describe('useCloseAnyOpenDropdown', () => {
expect(result.current.isDropdownOpen).toBe(false);
});
it('should close all nested dropdowns when multiple are open', () => {
jotaiStore.set(
isDropdownOpenComponentState.atomFamily({ instanceId: dropdownIdA }),
true,
);
jotaiStore.set(
isDropdownOpenComponentState.atomFamily({ instanceId: dropdownIdB }),
true,
);
jotaiStore.set(
isDropdownOpenComponentState.atomFamily({ instanceId: dropdownIdC }),
true,
);
jotaiStore.set(activeDropdownFocusIdState.atom, dropdownIdC);
jotaiStore.set(previousDropdownFocusIdStackState.atom, [
dropdownIdA,
dropdownIdB,
]);
const { result } = renderHook(() => useCloseAnyOpenDropdown(), {
wrapper: Wrapper,
});
act(() => {
result.current.closeAnyOpenDropdown();
});
expect(
jotaiStore.get(
isDropdownOpenComponentState.atomFamily({ instanceId: dropdownIdA }),
),
).toBe(false);
expect(
jotaiStore.get(
isDropdownOpenComponentState.atomFamily({ instanceId: dropdownIdB }),
),
).toBe(false);
expect(
jotaiStore.get(
isDropdownOpenComponentState.atomFamily({ instanceId: dropdownIdC }),
),
).toBe(false);
expect(jotaiStore.get(activeDropdownFocusIdState.atom)).toBeNull();
expect(jotaiStore.get(previousDropdownFocusIdStackState.atom)).toEqual([]);
});
it('should do nothing when no dropdowns are open', () => {
const { result } = renderHook(() => useCloseAnyOpenDropdown(), {
wrapper: Wrapper,
});
act(() => {
result.current.closeAnyOpenDropdown();
});
expect(jotaiStore.get(activeDropdownFocusIdState.atom)).toBeNull();
expect(jotaiStore.get(previousDropdownFocusIdStackState.atom)).toEqual([]);
});
});
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { previousDropdownFocusIdState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdState';
import { previousDropdownFocusIdStackState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdStackState';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
@@ -16,21 +16,16 @@ export const useCloseAnyOpenDropdown = () => {
const store = useStore();
const closeAnyOpenDropdown = useCallback(() => {
const previousDropdownFocusId = store.get(
previousDropdownFocusIdState.atom,
);
const previousStack = store.get(previousDropdownFocusIdStackState.atom);
const activeDropdownFocusId = store.get(activeDropdownFocusIdState.atom);
const thereIsNoDropdownOpen =
!isDefined(activeDropdownFocusId) && !isDefined(previousDropdownFocusId);
!isDefined(activeDropdownFocusId) && previousStack.length === 0;
if (thereIsNoDropdownOpen) {
return;
}
const thereIsOneNestedDropdownOpen = isDefined(previousDropdownFocusId);
if (isDefined(activeDropdownFocusId)) {
closeDropdown(activeDropdownFocusId);
removeFocusItemFromFocusStackById({
@@ -38,14 +33,14 @@ export const useCloseAnyOpenDropdown = () => {
});
}
if (thereIsOneNestedDropdownOpen) {
closeDropdown(previousDropdownFocusId);
for (const previousId of previousStack) {
closeDropdown(previousId);
removeFocusItemFromFocusStackById({
focusId: previousDropdownFocusId,
focusId: previousId,
});
}
store.set(previousDropdownFocusIdState.atom, null);
store.set(previousDropdownFocusIdStackState.atom, []);
store.set(activeDropdownFocusIdState.atom, null);
}, [closeDropdown, removeFocusItemFromFocusStackById, store]);
@@ -1,20 +1,23 @@
import { useCallback } from 'react';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { previousDropdownFocusIdState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdState';
import { previousDropdownFocusIdStackState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdStackState';
import { useStore } from 'jotai';
// TODO: this won't work for more than 1 nested dropdown
export const useGoBackToPreviousDropdownFocusId = () => {
const store = useStore();
const goBackToPreviousDropdownFocusId = useCallback(() => {
const previouslyFocusedDropdownId = store.get(
previousDropdownFocusIdState.atom,
);
const previousStack = store.get(previousDropdownFocusIdStackState.atom);
const previouslyFocusedDropdownId =
previousStack[previousStack.length - 1] ?? null;
store.set(activeDropdownFocusIdState.atom, previouslyFocusedDropdownId);
store.set(previousDropdownFocusIdState.atom, null);
store.set(
previousDropdownFocusIdStackState.atom,
previousStack.slice(0, -1),
);
}, [store]);
return {
@@ -1,8 +1,9 @@
import { useCallback } from 'react';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { previousDropdownFocusIdState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdState';
import { previousDropdownFocusIdStackState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdStackState';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
export const useSetActiveDropdownFocusIdAndMemorizePrevious = () => {
const store = useStore();
@@ -15,7 +16,14 @@ export const useSetActiveDropdownFocusIdAndMemorizePrevious = () => {
return;
}
store.set(previousDropdownFocusIdState.atom, activeDropdownFocusId);
if (isDefined(activeDropdownFocusId) && isDefined(dropdownId)) {
const previousStack = store.get(previousDropdownFocusIdStackState.atom);
store.set(previousDropdownFocusIdStackState.atom, [
...previousStack,
activeDropdownFocusId,
]);
}
store.set(activeDropdownFocusIdState.atom, dropdownId);
},
[store],
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const previousDropdownFocusIdStackState = createAtomState<string[]>({
key: 'previousDropdownFocusIdStackState',
defaultValue: [],
});
@@ -1,6 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const previousDropdownFocusIdState = createAtomState<string | null>({
key: 'previousDropdownFocusIdState',
defaultValue: null,
});