Skip to content

Commit ee6e797

Browse files
committed
feat(Calendar): allow to controll available modes (days, months, quartes, years)
1 parent 6cbc4a4 commit ee6e797

File tree

12 files changed

+234
-80
lines changed

12 files changed

+234
-80
lines changed

src/components/Calendar/Calendar.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,16 @@ $block: '.#{variables.$ns}calendar';
185185
gap: var(--_--calendar-days-gap);
186186

187187
&_mode_months,
188+
&_mode_quarters,
188189
&_mode_years {
189190
grid-row: 1 / -1;
190191

191192
padding: 12px 0 0;
192193
}
194+
195+
&-header {
196+
align-self: center;
197+
}
193198
}
194199

195200
&__grid-row {

src/components/Calendar/CalendarBase.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {useCalendarCellProps} from './hooks/useCalendarCellProps';
1111
import {useCalendarGridProps} from './hooks/useCalendarGridProps';
1212
import {useCalendarProps} from './hooks/useCalendarProps';
1313
import type {CalendarState} from './hooks/useCalendarState';
14-
import {getDaysInPeriod, getWeekDays} from './utils';
14+
import {calendarLayouts, getDaysInPeriod, getWeekDays} from './utils';
1515

1616
import './Calendar.scss';
1717

@@ -51,7 +51,8 @@ export const CalendarBase = React.forwardRef<CalendarInstance, CalendarBaseProps
5151
<div {...calendarProps} className={b({size: props.size})}>
5252
<div className={b('header')}>
5353
<Button {...modeButtonProps} view="flat" size={props.size}>
54-
{state.mode === 'years' ? (
54+
{state.availableModes.indexOf(state.mode) + 1 ===
55+
state.availableModes.length ? (
5556
<span key="label" className={b('mode-label', b(`years-label`))}>
5657
{modeButtonProps.children}
5758
</span>
@@ -95,10 +96,7 @@ function CalendarGrid({state}: CalendarGridProps) {
9596

9697
let animation;
9798
if (modeChanged) {
98-
if (
99-
(state.mode === 'days' && prevState.mode === 'months') ||
100-
(state.mode === 'months' && prevState.mode === 'years')
101-
) {
99+
if (calendarLayouts.indexOf(prevState.mode) > calendarLayouts.indexOf(state.mode)) {
102100
animation = 'zoom-out';
103101
} else {
104102
animation = 'zoom-in';
@@ -179,12 +177,17 @@ interface CalendarGridProps {
179177
}
180178
function CalendarGridCells({state}: CalendarGridProps) {
181179
const rowsInPeriod = state.mode === 'days' ? 6 : 4;
182-
const columnsInRow = state.mode === 'days' ? 7 : 3;
180+
const columnsInRow = state.mode === 'days' ? 7 : 3 + (state.mode === 'quarters' ? 1 : 0);
183181
const days = getDaysInPeriod(state.startDate, state.endDate, state.mode);
184182
return (
185183
<div className={b('grid-rowgroup', {mode: state.mode})} role="rowgroup">
186184
{[...new Array(rowsInPeriod).keys()].map((rowIndex) => (
187185
<div key={rowIndex} className={b('grid-row')} role="row">
186+
{state.mode === 'quarters' ? (
187+
<span role="rowheader" className={b('grid-rowgroup-header')}>
188+
{days[rowIndex * columnsInRow].format('YYYY')}
189+
</span>
190+
) : null}
188191
{days
189192
.slice(rowIndex * columnsInRow, (rowIndex + 1) * columnsInRow)
190193
.map((date) => {

src/components/Calendar/__stories__/Calendar.stories.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import React from 'react';
2+
13
import {dateTime, dateTimeParse} from '@gravity-ui/date-utils';
24
import type {DateTime} from '@gravity-ui/date-utils';
5+
import {Tabs} from '@gravity-ui/uikit';
36
import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18';
47
import type {Meta, StoryObj} from '@storybook/react';
58

@@ -15,7 +18,7 @@ export default meta;
1518

1619
type Story = StoryObj<typeof Calendar>;
1720

18-
export const Default: Story = {
21+
export const Default = {
1922
render: (args) => {
2023
const timeZone = args.timeZone;
2124
const props = {
@@ -88,7 +91,7 @@ export const Default: Story = {
8891
},
8992
},
9093
},
91-
};
94+
} satisfies Story;
9295

9396
function getIsDateUnavailable(variant: string) {
9497
if (variant === 'weekend') {
@@ -115,3 +118,29 @@ function getIsDateUnavailable(variant: string) {
115118

116119
return undefined;
117120
}
121+
122+
export const Custom: Story = {
123+
...Default,
124+
render: function Custom(args) {
125+
const [mode, setMode] = React.useState('days');
126+
127+
return (
128+
<div>
129+
<Tabs
130+
activeTab={mode}
131+
onSelectTab={(id) => {
132+
setMode(id);
133+
}}
134+
items={['days', 'months', 'quarters', 'years'].map((item) => ({
135+
id: item,
136+
title: item[0].toUpperCase() + item.slice(1),
137+
}))}
138+
/>
139+
{Default.render?.({...args, modes: {[mode]: true}})}
140+
</div>
141+
);
142+
},
143+
parameters: {
144+
controls: {exclude: ['mode', 'defaultMode', 'modes']},
145+
},
146+
};

src/components/Calendar/__stories__/RangeCalendar.stories.tsx

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

33
import {dateTime, dateTimeParse} from '@gravity-ui/date-utils';
44
import type {DateTime} from '@gravity-ui/date-utils';
5+
import {Tabs} from '@gravity-ui/uikit';
56
import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18';
67
import type {Meta, StoryObj} from '@storybook/react';
78

@@ -18,7 +19,7 @@ export default meta;
1819

1920
type Story = StoryObj<typeof RangeCalendar>;
2021

21-
export const Default: Story = {
22+
export const Default = {
2223
render: function Render(args) {
2324
const timeZone = args.timeZone;
2425
const props = {
@@ -60,7 +61,7 @@ export const Default: Story = {
6061
type: 'success',
6162
content: (
6263
<div>
63-
<div>date: {JSON.stringify(res, null, 2) || 'null'}</div>
64+
<div>date: {`${res.start.format('L')} - ${res.end.format('L')}`}</div>
6465
</div>
6566
),
6667
});
@@ -94,7 +95,7 @@ export const Default: Story = {
9495
},
9596
},
9697
},
97-
};
98+
} satisfies Story;
9899

99100
function getIsDateUnavailable(variant: string) {
100101
if (variant === 'weekend') {
@@ -121,3 +122,29 @@ function getIsDateUnavailable(variant: string) {
121122

122123
return undefined;
123124
}
125+
126+
export const Custom: Story = {
127+
...Default,
128+
render: function Custom(args) {
129+
const [mode, setMode] = React.useState('days');
130+
131+
return (
132+
<div>
133+
<Tabs
134+
activeTab={mode}
135+
onSelectTab={(id) => {
136+
setMode(id);
137+
}}
138+
items={['days', 'months', 'quarters', 'years'].map((item) => ({
139+
id: item,
140+
title: item[0].toUpperCase() + item.slice(1),
141+
}))}
142+
/>
143+
{Default.render?.({...args, modes: {[mode]: true}})}
144+
</div>
145+
);
146+
},
147+
parameters: {
148+
controls: {exclude: ['mode', 'defaultMode', 'modes']},
149+
},
150+
};

src/components/Calendar/hooks/types.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type {DateTime} from '@gravity-ui/date-utils';
22

33
import type {InputBase, RangeValue} from '../../types';
44

5-
export type CalendarLayout = 'days' | 'months' | 'years';
5+
export type CalendarLayout = 'days' | 'months' | 'quarters' | 'years';
66

77
export interface CalendarStateOptionsBase extends InputBase {
88
/** The minimum allowed date that a user may select. */
@@ -30,6 +30,8 @@ export interface CalendarStateOptionsBase extends InputBase {
3030
defaultMode?: CalendarLayout;
3131
/** Handler that is called when the mode changes */
3232
onUpdateMode?: (mode: CalendarLayout) => void;
33+
/** Controls which modes to use */
34+
modes?: Partial<Record<CalendarLayout, boolean>>;
3335
}
3436

3537
interface CalendarStateBase {
@@ -49,7 +51,7 @@ interface CalendarStateBase {
4951
/** Selects the currently focused date. */
5052
selectFocusedDate(): void;
5153
/** Selects the given date. */
52-
selectDate(date: DateTime): void;
54+
selectDate(date: DateTime, force?: boolean): void;
5355
/** Moves focus to the next calendar date. */
5456
focusNextCell(): void;
5557
/** Moves focus to the previous calendar date. */
@@ -102,6 +104,7 @@ interface CalendarStateBase {
102104
readonly mode: CalendarLayout;
103105
/** Sets calendar layout */
104106
setMode(mode: CalendarLayout): void;
107+
readonly availableModes: CalendarLayout[];
105108
readonly startDate: DateTime;
106109
readonly endDate: DateTime;
107110
}

src/components/Calendar/hooks/useCalendarCellProps.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import React from 'react';
33
import {dateTime} from '@gravity-ui/date-utils';
44
import type {DateTime} from '@gravity-ui/date-utils';
55

6-
import type {CalendarState, RangeCalendarState} from './types';
6+
import type {CalendarLayout, CalendarState, RangeCalendarState} from './types';
77

88
export function useCalendarCellProps(date: DateTime, state: CalendarState | RangeCalendarState) {
99
const ref = React.useRef<HTMLDivElement>(null);
@@ -31,14 +31,7 @@ export function useCalendarCellProps(date: DateTime, state: CalendarState | Rang
3131
const isCurrent = dateTime().isSame(date, state.mode);
3232
const isWeekend = state.isWeekend(date);
3333

34-
let label = '';
35-
if (state.mode === 'days') {
36-
label = `${date.format('dddd')}, ${date.format('LL')}`;
37-
} else if (state.mode === 'months') {
38-
label = `${date.format('MMMM YYYY')}`;
39-
} else if (state.mode === 'years') {
40-
label = `${date.format('YYYY')}`;
41-
}
34+
const label = getDateLabel(date, state.mode);
4235

4336
const cellProps: React.HTMLAttributes<unknown> = {
4437
role: 'gridcell',
@@ -55,11 +48,7 @@ export function useCalendarCellProps(date: DateTime, state: CalendarState | Rang
5548
onClick: isSelectable
5649
? () => {
5750
state.setFocusedDate(date);
58-
if (state.mode === 'days') {
59-
state.selectDate(date);
60-
} else {
61-
state.zoomIn();
62-
}
51+
state.selectDate(date);
6352
}
6453
: undefined,
6554
onPointerEnter() {
@@ -79,6 +68,8 @@ export function useCalendarCellProps(date: DateTime, state: CalendarState | Rang
7968
let formattedDate = date.format('D');
8069
if (state.mode === 'months') {
8170
formattedDate = date.format('MMM');
71+
} else if (state.mode === 'quarters') {
72+
formattedDate = date.format('[Q]Q');
8273
} else if (state.mode === 'years') {
8374
formattedDate = date.format('YYYY');
8475
}
@@ -98,3 +89,17 @@ export function useCalendarCellProps(date: DateTime, state: CalendarState | Rang
9889
isWeekend,
9990
};
10091
}
92+
93+
function getDateLabel(date: DateTime, mode: CalendarLayout) {
94+
let label = '';
95+
if (mode === 'days') {
96+
label = `${date.format('dddd')}, ${date.format('LL')}`;
97+
} else if (mode === 'months') {
98+
label = `${date.format('MMMM YYYY')}`;
99+
} else if (mode === 'quarters') {
100+
label = `${date.format('[Q]Q YYYY')}`;
101+
} else if (mode === 'years') {
102+
label = `${date.format('YYYY')}`;
103+
}
104+
return label;
105+
}

src/components/Calendar/hooks/useCalendarGridProps.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function useCalendarGridProps(state: CalendarState | RangeCalendarState)
1414
const gridProps: React.HTMLAttributes<HTMLElement> = {
1515
role: 'grid',
1616
'aria-label':
17-
state.mode === 'years'
17+
state.mode === 'years' || state.mode === 'quarters'
1818
? `${state.startDate.year()}${state.endDate.year()}`
1919
: state.focusedDate.format(state.mode === 'days' ? 'MMMM YYYY' : 'YYYY'),
2020
'aria-disabled': state.disabled ? 'true' : undefined,
@@ -42,11 +42,7 @@ export function useCalendarGridProps(state: CalendarState | RangeCalendarState)
4242
} else if (e.code === 'Equal') {
4343
state.zoomIn();
4444
} else if (e.key === 'Enter' || e.key === ' ') {
45-
if (state.mode === 'days') {
46-
state.selectFocusedDate();
47-
} else {
48-
state.setMode(state.mode === 'months' ? 'days' : 'months');
49-
}
45+
state.selectDate(state.focusedDate);
5046
}
5147
},
5248
};

src/components/Calendar/hooks/useCalendarProps.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const buttonDisabledClassName = 'yc-button_disabled g-button_disabled';
1313
// eslint-disable-next-line complexity
1414
export function useCalendarProps(props: CalendarProps, state: CalendarState | RangeCalendarState) {
1515
const title =
16-
state.mode === 'years'
16+
state.mode === 'years' || state.mode === 'quarters'
1717
? `${state.startDate.year()}${state.endDate.year()}`
1818
: state.focusedDate.format(state.mode === 'days' ? 'MMMM YYYY' : 'YYYY');
1919

@@ -33,7 +33,10 @@ export function useCalendarProps(props: CalendarProps, state: CalendarState | Ra
3333
...focusWithinProps,
3434
};
3535

36-
const modeDisabled = state.disabled || state.mode === 'years';
36+
const modeIndex = state.availableModes.indexOf(state.mode);
37+
const isModeLast = modeIndex + 1 === state.availableModes.length;
38+
const isNextModeLast = modeIndex + 2 === state.availableModes.length;
39+
const modeDisabled = state.disabled || isModeLast;
3740

3841
const modeButtonProps: ButtonProps = {
3942
disabled: state.disabled,
@@ -43,7 +46,7 @@ export function useCalendarProps(props: CalendarProps, state: CalendarState | Ra
4346
? undefined
4447
: () => {
4548
state.zoomOut();
46-
if (state.mode === 'months') {
49+
if (isNextModeLast) {
4750
state.setFocused(true);
4851
}
4952
},

0 commit comments

Comments
 (0)