Skip to content

Commit 2bc50b6

Browse files
committed
feat(DatePicker, RelativeDatePicker): allow to render custom calendar
1 parent ee6e797 commit 2bc50b6

File tree

12 files changed

+172
-77
lines changed

12 files changed

+172
-77
lines changed

src/components/DateField/hooks/useDateFieldProps.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,10 @@ export function useDateFieldProps(
136136
state.focusNextSection();
137137
} else if (e.key === 'Home') {
138138
e.preventDefault();
139-
state.focusFirstSection();
139+
state.decrementToMin();
140140
} else if (e.key === 'End') {
141141
e.preventDefault();
142-
state.focusLastSection();
142+
state.incrementToMax();
143143
} else if (e.key === 'ArrowUp' && !e.altKey) {
144144
e.preventDefault();
145145
state.increment();

src/components/DateField/hooks/useDateFieldState.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import type {DateTime} from '@gravity-ui/date-utils';
66
import {useControlledState} from '../../hooks/useControlledState';
77
import type {DateFieldBase} from '../../types/datePicker';
88
import type {ValidationState} from '../../types/inputs';
9+
import {createPlaceholderValue, isInvalid, mergeDateTime} from '../../utils/dates';
910
import type {
1011
DateFieldSection,
1112
DateFieldSectionType,
1213
DateFieldSectionWithoutPosition,
1314
} from '../types';
14-
import {createPlaceholderValue, isInvalid, mergeDateTime, splitFormatIntoSections} from '../utils';
15+
import {splitFormatIntoSections} from '../utils';
1516

1617
export interface DateFieldStateOptions extends DateFieldBase {}
1718

@@ -91,6 +92,8 @@ export type DateFieldState = {
9192
* Upon reaching the minimum or maximum value, the value wraps around to the opposite limit.
9293
*/
9394
decrementPage: () => void;
95+
incrementToMax: () => void;
96+
decrementToMin: () => void;
9497
/** Clears the value of the currently selected segment, reverting it to the placeholder. */
9598
clearSection: () => void;
9699
/** Clears all segments, reverting them to the placeholder. */
@@ -329,6 +332,32 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState
329332
adjustSection(sectionIndex, -(PAGE_STEP[this.sections[sectionIndex].type] || 1));
330333
}
331334
},
335+
incrementToMax() {
336+
if (this.readOnly || this.disabled) {
337+
return;
338+
}
339+
enteredKeys.current = '';
340+
const sectionIndex = getCurrentEditableSectionIndex(this.sections, selectedSections);
341+
if (sectionIndex !== -1) {
342+
const section = this.sections[sectionIndex];
343+
if (typeof section.maxValue === 'number') {
344+
setSection(section, section.maxValue);
345+
}
346+
}
347+
},
348+
decrementToMin() {
349+
if (this.readOnly || this.disabled) {
350+
return;
351+
}
352+
enteredKeys.current = '';
353+
const sectionIndex = getCurrentEditableSectionIndex(this.sections, selectedSections);
354+
if (sectionIndex !== -1) {
355+
const section = this.sections[sectionIndex];
356+
if (typeof section.minValue === 'number') {
357+
setSection(section, section.minValue);
358+
}
359+
}
360+
},
332361
clearSection() {
333362
if (this.readOnly || this.disabled) {
334363
return;

src/components/DateField/types.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
1-
export type DateFieldSectionType = Exclude<Intl.DateTimeFormatPartTypes, 'era'>;
1+
export type DateFieldSectionType = Extract<
2+
Intl.DateTimeFormatPartTypes,
3+
| 'day'
4+
| 'dayPeriod'
5+
| 'hour'
6+
| 'literal'
7+
| 'minute'
8+
| 'month'
9+
| 'second'
10+
| 'timeZoneName'
11+
| 'weekday'
12+
| 'year'
13+
| 'unknown'
14+
>;
215

316
export type DateFormatTokenMap = {
417
[formatToken: string]:

src/components/DateField/utils.ts

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {dateTime, settings} from '@gravity-ui/date-utils';
2-
import type {DateTime} from '@gravity-ui/date-utils';
32

43
import {i18n} from './i18n';
54
import type {
@@ -337,39 +336,3 @@ function getSectionOptions(
337336

338337
return undefined;
339338
}
340-
341-
interface PlaceholderValueOptions {
342-
placeholderValue?: DateTime;
343-
timeZone?: string;
344-
}
345-
export function createPlaceholderValue({placeholderValue, timeZone}: PlaceholderValueOptions) {
346-
return (
347-
placeholderValue ?? dateTime({timeZone}).set('hour', 0).set('minute', 0).set('second', 0)
348-
);
349-
}
350-
351-
export function isInvalid(
352-
value: DateTime | null | undefined,
353-
minValue: DateTime | undefined,
354-
maxValue: DateTime | undefined,
355-
) {
356-
if (!value) {
357-
return false;
358-
}
359-
360-
if (minValue && value.isBefore(minValue)) {
361-
return true;
362-
}
363-
if (maxValue && maxValue.isBefore(value)) {
364-
return true;
365-
}
366-
367-
return false;
368-
}
369-
370-
export function mergeDateTime(date: DateTime, time: DateTime) {
371-
return date
372-
.set('hours', time.hour())
373-
.set('minutes', time.minute())
374-
.set('seconds', time.second());
375-
}

src/components/DatePicker/DatePicker.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22

33
import {TextInput, useFocusWithin, useMobile} from '@gravity-ui/uikit';
44

5+
import type {CalendarProps} from '../Calendar';
56
import {useDateFieldProps, useDateFieldState} from '../DateField';
67
import type {
78
AccessibilityProps,
@@ -27,7 +28,9 @@ export interface DatePickerProps
2728
KeyboardEvents,
2829
DomProps,
2930
StyleProps,
30-
AccessibilityProps {}
31+
AccessibilityProps {
32+
children?: (props: CalendarProps) => React.ReactNode;
33+
}
3134

3235
export function DatePicker({
3336
value,
@@ -36,6 +39,7 @@ export function DatePicker({
3639
className,
3740
onFocus,
3841
onBlur,
42+
children,
3943
...props
4044
}: DatePickerProps) {
4145
const anchorRef = React.useRef<HTMLDivElement>(null);
@@ -94,7 +98,12 @@ export function DatePicker({
9498
{isMobile ? (
9599
<MobileCalendar props={props} state={state} />
96100
) : (
97-
<DesktopCalendar anchorRef={anchorRef} props={props} state={state} />
101+
<DesktopCalendar
102+
anchorRef={anchorRef}
103+
props={props}
104+
state={state}
105+
renderCalendar={children}
106+
/>
98107
)}
99108
<TextInput
100109
{...inputProps}

src/components/DatePicker/DesktopCalendar.tsx

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import React from 'react';
33
import {Calendar as CalendarIcon} from '@gravity-ui/icons';
44
import {Button, Icon, Popup, useFocusWithin} from '@gravity-ui/uikit';
55

6-
import {Calendar} from '../Calendar';
6+
import {Calendar, type CalendarProps} from '../Calendar';
77
import {DateField} from '../DateField';
88
import {getButtonSizeForInput} from '../utils/getButtonSizeForInput';
99

@@ -16,8 +16,9 @@ interface DesktopCalendarProps {
1616
anchorRef: React.RefObject<HTMLElement>;
1717
props: DatePickerProps;
1818
state: DatePickerState;
19+
renderCalendar?: (props: CalendarProps) => React.ReactNode;
1920
}
20-
export function DesktopCalendar({anchorRef, props, state}: DesktopCalendarProps) {
21+
export function DesktopCalendar({anchorRef, props, state, renderCalendar}: DesktopCalendarProps) {
2122
const {focusWithinProps} = useFocusWithin({
2223
isDisabled: !state.isOpen,
2324
onBlurWithin: () => {
@@ -29,6 +30,22 @@ export function DesktopCalendar({anchorRef, props, state}: DesktopCalendarProps)
2930
return null;
3031
}
3132

33+
const calendarProps: CalendarProps = {
34+
autoFocus: true,
35+
size: props.size === 's' ? 'm' : props.size,
36+
disabled: props.disabled,
37+
readOnly: props.readOnly,
38+
onUpdate: (d) => {
39+
state.setDateValue(d);
40+
},
41+
defaultFocusedValue: state.dateValue ?? undefined,
42+
value: state.dateValue,
43+
minValue: props.minValue,
44+
maxValue: props.maxValue,
45+
isDateUnavailable: props.isDateUnavailable,
46+
timeZone: props.timeZone,
47+
};
48+
3249
return (
3350
<Popup
3451
open={state.isOpen}
@@ -38,23 +55,12 @@ export function DesktopCalendar({anchorRef, props, state}: DesktopCalendarProps)
3855
}}
3956
restoreFocus
4057
>
41-
<div {...focusWithinProps} className={b('popup-content')}>
42-
<Calendar
43-
// eslint-disable-next-line jsx-a11y/no-autofocus
44-
autoFocus
45-
size={props.size === 's' ? 'm' : props.size}
46-
disabled={props.disabled}
47-
readOnly={props.readOnly}
48-
onUpdate={(d) => {
49-
state.setDateValue(d);
50-
}}
51-
defaultFocusedValue={state.dateValue ?? undefined}
52-
value={state.dateValue}
53-
minValue={props.minValue}
54-
maxValue={props.maxValue}
55-
isDateUnavailable={props.isDateUnavailable}
56-
timeZone={props.timeZone}
57-
/>
58+
<div {...focusWithinProps} className={b('popup-content')} tabIndex={-1}>
59+
{typeof renderCalendar === 'function' ? (
60+
renderCalendar(calendarProps)
61+
) : (
62+
<Calendar {...calendarProps} />
63+
)}
5864
{state.hasTime && (
5965
<div className={b('time-field-wrapper')}>
6066
<DateField

src/components/DatePicker/MobileCalendar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {Calendar as CalendarIcon} from '@gravity-ui/icons';
44
import {Icon} from '@gravity-ui/uikit';
55

66
import {block} from '../../utils/cn';
7-
import {createPlaceholderValue, mergeDateTime} from '../DateField/utils';
7+
import {createPlaceholderValue, mergeDateTime} from '../utils/dates';
88
import {getButtonSizeForInput} from '../utils/getButtonSizeForInput';
99

1010
import type {DatePickerProps} from './DatePicker';

src/components/DatePicker/__stories__/DatePicker.stories.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import React from 'react';
2+
13
import {dateTimeParse} from '@gravity-ui/date-utils';
4+
import {Tabs} from '@gravity-ui/uikit';
25
import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18';
36
import type {Meta, StoryObj} from '@storybook/react';
47

8+
import {Calendar} from '../../Calendar';
59
import {DatePicker} from '../DatePicker';
610

711
const meta: Meta<typeof DatePicker> = {
@@ -12,9 +16,10 @@ const meta: Meta<typeof DatePicker> = {
1216

1317
export default meta;
1418

19+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
1520
type Story = StoryObj<typeof DatePicker>;
1621

17-
export const Default: Story = {
22+
export const Default = {
1823
render: (args) => {
1924
const timeZone = args.timeZone;
2025
const props = {
@@ -84,4 +89,34 @@ export const Default: Story = {
8489
},
8590
},
8691
},
87-
};
92+
} satisfies Story;
93+
94+
export const WithCustomCalendar = {
95+
...Default,
96+
render: (args) => {
97+
return Default.render({
98+
...args,
99+
children: function CustomCalendar(props) {
100+
const [mode, setMode] = React.useState('days');
101+
102+
return (
103+
<div>
104+
<div style={{paddingInline: 5}}>
105+
<Tabs
106+
activeTab={mode}
107+
onSelectTab={(id) => {
108+
setMode(id);
109+
}}
110+
items={['days', 'months', 'quarters', 'years'].map((item) => ({
111+
id: item,
112+
title: item[0].toUpperCase() + item.slice(1, -1),
113+
}))}
114+
/>
115+
</div>
116+
<Calendar {...props} modes={{[mode]: true}} />
117+
</div>
118+
);
119+
},
120+
});
121+
},
122+
} satisfies Story;

src/components/DatePicker/hooks/useDatePickerState.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import React from 'react';
22

33
import type {DateTime} from '@gravity-ui/date-utils';
44

5-
import {createPlaceholderValue, splitFormatIntoSections} from '../../DateField/utils';
5+
import {splitFormatIntoSections} from '../../DateField/utils';
66
import {useControlledState} from '../../hooks/useControlledState';
77
import type {InputBase, ValueBase} from '../../types';
8+
import {createPlaceholderValue, mergeDateTime} from '../../utils/dates';
89
export type Granularity = 'day' | 'hour' | 'minute' | 'second';
910

1011
export interface DatePickerState {
@@ -161,13 +162,6 @@ export function useDatePickerState(props: DatePickerStateOptions): DatePickerSta
161162
};
162163
}
163164

164-
function mergeDateTime(date: DateTime, time: DateTime) {
165-
return date
166-
.set('hours', time.hour())
167-
.set('minutes', time.minute())
168-
.set('seconds', time.second());
169-
}
170-
171165
function getPlaceholderTime(placeholderValue: DateTime | undefined, timeZone?: string) {
172166
return createPlaceholderValue({placeholderValue, timeZone});
173167
}

src/components/RelativeDatePicker/RelativeDatePicker.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {Button, Icon, Popup, TextInput, useMobile} from '@gravity-ui/uikit';
55

66
import {block} from '../../utils/cn';
77
import {Calendar} from '../Calendar';
8+
import type {CalendarProps} from '../Calendar';
89
import {DateField} from '../DateField';
910
import {MobileCalendar, MobileCalendarIcon} from '../DatePicker/MobileCalendar';
1011
import type {
@@ -31,7 +32,9 @@ export interface RelativeDatePickerProps
3132
KeyboardEvents,
3233
DomProps,
3334
StyleProps,
34-
AccessibilityProps {}
35+
AccessibilityProps {
36+
children?: (props: CalendarProps) => React.ReactNode;
37+
}
3538

3639
export function RelativeDatePicker(props: RelativeDatePickerProps) {
3740
const state = useRelativeDatePickerState(props);
@@ -90,7 +93,11 @@ export function RelativeDatePicker(props: RelativeDatePickerProps) {
9093
{!isMobile && (
9194
<Popup {...popupProps} anchorRef={anchorRef}>
9295
<div className={b('popup-content')}>
93-
<Calendar {...calendarProps} />
96+
{typeof props.children === 'function' ? (
97+
props.children(calendarProps)
98+
) : (
99+
<Calendar {...calendarProps} />
100+
)}
94101
{state.datePickerState.hasTime && (
95102
<div className={b('time-field-wrapper')}>
96103
<DateField {...timeInputProps} />

0 commit comments

Comments
 (0)