## 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:
@@ -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,
|
||||
|
||||
+10
-11
@@ -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>
|
||||
|
||||
+103
-93
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+227
-54
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+35
-7
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
+129
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+75
@@ -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,
|
||||
};
|
||||
};
|
||||
+3
-1
@@ -18,7 +18,9 @@ export const getTimeBlocks = (timeFormat: TimeFormat) => {
|
||||
maxLength: 2,
|
||||
},
|
||||
aa: {
|
||||
mask: '**',
|
||||
mask: IMask.MaskedEnum,
|
||||
enum: ['AM', 'PM'],
|
||||
prepareChar: (str: string) => str.toUpperCase(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+66
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
+7
-12
@@ -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]);
|
||||
|
||||
|
||||
+9
-6
@@ -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 {
|
||||
|
||||
+10
-2
@@ -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],
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const previousDropdownFocusIdStackState = createAtomState<string[]>({
|
||||
key: 'previousDropdownFocusIdStackState',
|
||||
defaultValue: [],
|
||||
});
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const previousDropdownFocusIdState = createAtomState<string | null>({
|
||||
key: 'previousDropdownFocusIdState',
|
||||
defaultValue: null,
|
||||
});
|
||||
Reference in New Issue
Block a user