refactor(component): new calendar-view DatePicker (#5654)
This commit is contained in:
@@ -5,7 +5,7 @@ import { useState } from 'react';
|
|||||||
import { AFFiNEDatePicker } from '.';
|
import { AFFiNEDatePicker } from '.';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'UI/Date Picker/Date Picker',
|
title: 'UI/Date Picker/AFFiNE Date Picker',
|
||||||
} satisfies Meta<typeof AFFiNEDatePicker>;
|
} satisfies Meta<typeof AFFiNEDatePicker>;
|
||||||
|
|
||||||
const _format = 'YYYY-MM-DD';
|
const _format = 'YYYY-MM-DD';
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { createVar, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
// variables
|
||||||
|
export const vars = {
|
||||||
|
gapX: createVar('gapX'),
|
||||||
|
gapY: createVar('gapY'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// basic
|
||||||
|
export const spacer = style({ flex: 1 });
|
||||||
|
export const spacerX = style([spacer, { width: 0 }]);
|
||||||
|
|
||||||
|
// interactive style
|
||||||
|
export const basicInteractive = style({
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
selectors: {
|
||||||
|
'&::before, &::after': {
|
||||||
|
content: '',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
export const hoverInteractive = style([
|
||||||
|
basicInteractive,
|
||||||
|
{
|
||||||
|
selectors: {
|
||||||
|
'&::after': {
|
||||||
|
transition: 'background-color 0.2s ease',
|
||||||
|
},
|
||||||
|
'&:hover::after': {
|
||||||
|
backgroundColor: cssVar('hoverColor'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
export const focusInteractive = style([
|
||||||
|
basicInteractive,
|
||||||
|
{
|
||||||
|
selectors: {
|
||||||
|
'&::before': {
|
||||||
|
opacity: 0,
|
||||||
|
boxShadow: `0 0 0 2px ${cssVar('brandColor')}`,
|
||||||
|
},
|
||||||
|
'&::after': {
|
||||||
|
border: '1px solid transparent',
|
||||||
|
},
|
||||||
|
|
||||||
|
'&:focus-visible::before': {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
'&:focus-visible::after': {
|
||||||
|
borderColor: cssVar('brandColor'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
export const disabledInteractive = style([
|
||||||
|
basicInteractive,
|
||||||
|
{
|
||||||
|
selectors: {
|
||||||
|
'&[disabled], &[aria-disabled="true"]': {
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
color: cssVar('textDisableColor'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
export const interactive = style([
|
||||||
|
focusInteractive,
|
||||||
|
hoverInteractive,
|
||||||
|
disabledInteractive,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const basicCell = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minWidth: '28px',
|
||||||
|
maxWidth: '56px',
|
||||||
|
flex: '1',
|
||||||
|
userSelect: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
// roots
|
||||||
|
export const calendarRoot = style({});
|
||||||
|
export const calendarWrapper = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: vars.gapY,
|
||||||
|
});
|
||||||
|
export const calendarHeader = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
// header
|
||||||
|
export const headerLayoutCell = style([basicCell]);
|
||||||
|
export const headerLayoutCellOrigin = style({
|
||||||
|
width: 0,
|
||||||
|
height: 'fit-content',
|
||||||
|
display: 'flex',
|
||||||
|
selectors: {
|
||||||
|
'[data-is-left="true"] &': {
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
marginLeft: '-24px',
|
||||||
|
},
|
||||||
|
'[data-is-right="true"] &': {
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginRight: '-30px',
|
||||||
|
},
|
||||||
|
|
||||||
|
'[data-mode="month"] [data-is-left="true"] &': {
|
||||||
|
marginLeft: '-36px',
|
||||||
|
},
|
||||||
|
'[data-mode="month"] [data-is-right="true"] &': {
|
||||||
|
marginRight: '-44px',
|
||||||
|
},
|
||||||
|
|
||||||
|
'[data-mode="year"] [data-is-left="true"] &': {
|
||||||
|
marginLeft: '-48px',
|
||||||
|
},
|
||||||
|
'[data-mode="year"] [data-is-right="true"] &': {
|
||||||
|
marginRight: '-52px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
export const calendarHeaderTriggerButton = style([
|
||||||
|
interactive,
|
||||||
|
{
|
||||||
|
display: 'inline-flex',
|
||||||
|
lineHeight: '22px',
|
||||||
|
fontSize: 'var(--affine-font-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
export const headerNavButtons = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
});
|
||||||
|
export const headerNavGapFallback = style({
|
||||||
|
width: 8,
|
||||||
|
});
|
||||||
|
export const headerNavToday = style([
|
||||||
|
interactive,
|
||||||
|
{
|
||||||
|
fontSize: 'var(--affine-font-sm)',
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '22px',
|
||||||
|
padding: '0 4px',
|
||||||
|
borderRadius: 4,
|
||||||
|
color: cssVar('iconColor'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// month view body
|
||||||
|
export const monthViewBody = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: vars.gapY,
|
||||||
|
});
|
||||||
|
export const monthViewRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: vars.gapX,
|
||||||
|
});
|
||||||
|
export const monthViewHeaderCell = style([
|
||||||
|
basicCell,
|
||||||
|
{
|
||||||
|
fontSize: 'var(--affine-font-xs)',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
height: 28,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
export const monthViewBodyCell = style([
|
||||||
|
basicCell,
|
||||||
|
{
|
||||||
|
height: '28px',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
export const monthViewBodyCellInner = style([
|
||||||
|
interactive,
|
||||||
|
{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
fontWeight: 400,
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'&[data-is-today="true"]': {
|
||||||
|
fontWeight: 600,
|
||||||
|
color: cssVar('brandColor'),
|
||||||
|
},
|
||||||
|
'&[data-not-current-month="true"]': {
|
||||||
|
color: cssVar('black10'),
|
||||||
|
},
|
||||||
|
'&[data-selected="true"]': {
|
||||||
|
backgroundColor: cssVar('brandColor'),
|
||||||
|
fontWeight: 500,
|
||||||
|
color: cssVar('pureWhite'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// year view body
|
||||||
|
export const yearViewBody = style([monthViewBody, { gap: 18, paddingTop: 18 }]);
|
||||||
|
export const yearViewRow = style([monthViewRow]);
|
||||||
|
export const yearViewBodyCell = style([monthViewBodyCell, { height: 34 }]);
|
||||||
|
export const yearViewBodyCellInner = style([
|
||||||
|
monthViewBodyCellInner,
|
||||||
|
{
|
||||||
|
fontSize: cssVar('fontBase'),
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '24px',
|
||||||
|
selectors: {
|
||||||
|
// no highlight
|
||||||
|
// '&[data-is-today="true"]': {},
|
||||||
|
'&[data-selected="true"]': {
|
||||||
|
background: 'transparent',
|
||||||
|
color: cssVar('textEmphasisColor'),
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// decade view body
|
||||||
|
export const decadeViewBody = style([yearViewBody]);
|
||||||
|
export const decadeViewRow = style([yearViewRow]);
|
||||||
|
export const decadeViewBodyCell = style([
|
||||||
|
yearViewBodyCell,
|
||||||
|
{
|
||||||
|
maxWidth: 100,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
export const decadeViewBodyCellInner = style([yearViewBodyCellInner]);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Meta, StoryFn } from '@storybook/react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { ResizePanel } from '../../resize-panel/resize-panel';
|
||||||
|
import { DatePicker } from '.';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'UI/Date Picker/Date Picker',
|
||||||
|
} satisfies Meta<typeof DatePicker>;
|
||||||
|
|
||||||
|
const _format = 'YYYY-MM-DD';
|
||||||
|
|
||||||
|
const Template: StoryFn<typeof DatePicker> = args => {
|
||||||
|
const [date, setDate] = useState(dayjs().format(_format));
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: 400, maxWidth: 600, margin: '0 auto' }}>
|
||||||
|
<div style={{ marginBottom: 20 }}>Selected Date: {date}</div>
|
||||||
|
|
||||||
|
<ResizePanel
|
||||||
|
horizontal
|
||||||
|
vertical={false}
|
||||||
|
width={256}
|
||||||
|
minWidth={256 + 8 * 2}
|
||||||
|
maxWidth={1200}
|
||||||
|
>
|
||||||
|
<DatePicker value={date} onChange={setDate} {...args} />
|
||||||
|
</ResizePanel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Basic: StoryFn<typeof DatePicker> = Template.bind(undefined);
|
||||||
|
Basic.args = {
|
||||||
|
format: 'YYYYMMDD',
|
||||||
|
gapX: 8,
|
||||||
|
gapY: 8,
|
||||||
|
monthNames: 'Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec',
|
||||||
|
weekDays: 'Su,Mo,Tu,We,Th,Fr,Sa',
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './calendar.css';
|
||||||
|
import { DATE_MAX, DATE_MIN } from './constants';
|
||||||
|
import { DayPicker } from './day-picker';
|
||||||
|
import { MonthPicker } from './month-picker';
|
||||||
|
import type { SelectMode } from './types';
|
||||||
|
import { type DatePickerProps, defaultDatePickerProps } from './types';
|
||||||
|
import { YearPicker } from './year-picker';
|
||||||
|
|
||||||
|
export type { DatePickerProps } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline DatePicker
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const DatePicker = (props: DatePickerProps) => {
|
||||||
|
const finalProps = { ...defaultDatePickerProps, ...props };
|
||||||
|
const { value, gapX, gapY, onChange } = finalProps;
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<SelectMode>('day');
|
||||||
|
const [cursor, setCursor] = useState(dayjs(value));
|
||||||
|
|
||||||
|
const variables = assignInlineVars({
|
||||||
|
[styles.vars.gapX]: `${gapX}px`,
|
||||||
|
[styles.vars.gapY]: `${gapY}px`,
|
||||||
|
});
|
||||||
|
const Component =
|
||||||
|
mode === 'day' ? DayPicker : mode === 'month' ? MonthPicker : YearPicker;
|
||||||
|
|
||||||
|
const onPreChange = useCallback(
|
||||||
|
(v: string) => {
|
||||||
|
setMode('day');
|
||||||
|
setCursor(dayjs(v));
|
||||||
|
onChange?.(v);
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onCursorChange = useCallback((newCursor: dayjs.Dayjs) => {
|
||||||
|
// validate range
|
||||||
|
if (newCursor.isBefore(DATE_MIN)) newCursor = dayjs(DATE_MIN);
|
||||||
|
else if (newCursor.isAfter(DATE_MAX)) newCursor = dayjs(DATE_MAX);
|
||||||
|
setCursor(newCursor);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.calendarRoot} style={variables}>
|
||||||
|
<Component
|
||||||
|
cursor={cursor}
|
||||||
|
{...finalProps}
|
||||||
|
onChange={onPreChange}
|
||||||
|
onCursorChange={onCursorChange}
|
||||||
|
onModeChange={setMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export const DATE_MIN = '1970-01-01';
|
||||||
|
export const DATE_MAX = '2099-12-31';
|
||||||
|
|
||||||
|
export const YEAR_MIN = dayjs(DATE_MIN).year();
|
||||||
|
export const YEAR_MAX = dayjs(DATE_MAX).year();
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './calendar.css';
|
||||||
|
import { DATE_MAX, DATE_MIN } from './constants';
|
||||||
|
import { CalendarLayout, DefaultDateCell, NavButtons } from './items';
|
||||||
|
import type { DateCell, DatePickerModePanelProps } from './types';
|
||||||
|
|
||||||
|
export const DayPicker = memo(function DayPicker(
|
||||||
|
props: DatePickerModePanelProps
|
||||||
|
) {
|
||||||
|
const dayPickerRootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const headerMonthRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
value,
|
||||||
|
cursor,
|
||||||
|
weekDays,
|
||||||
|
monthNames,
|
||||||
|
format,
|
||||||
|
todayLabel,
|
||||||
|
customDayRenderer,
|
||||||
|
onChange,
|
||||||
|
onCursorChange,
|
||||||
|
onModeChange,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const matrix = useMemo(() => {
|
||||||
|
const firstDayOfMonth = cursor.startOf('month');
|
||||||
|
const firstDayOfFirstWeek = firstDayOfMonth.startOf('week');
|
||||||
|
|
||||||
|
const lastDayOfMonth = cursor.endOf('month');
|
||||||
|
const lastDayOfLastWeek = lastDayOfMonth.endOf('week');
|
||||||
|
|
||||||
|
const matrix = [];
|
||||||
|
let currentDay = firstDayOfFirstWeek;
|
||||||
|
while (currentDay.isBefore(lastDayOfLastWeek)) {
|
||||||
|
const week: DateCell[] = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
week.push({
|
||||||
|
date: currentDay,
|
||||||
|
label: currentDay.date().toString(),
|
||||||
|
isToday: currentDay.isSame(dayjs(), 'day'),
|
||||||
|
notCurrentMonth: !currentDay.isSame(cursor, 'month'),
|
||||||
|
selected: value ? currentDay.isSame(value, 'day') : false,
|
||||||
|
focused: currentDay.isSame(cursor, 'day'),
|
||||||
|
});
|
||||||
|
currentDay = currentDay.add(1, 'day');
|
||||||
|
}
|
||||||
|
matrix.push(week);
|
||||||
|
}
|
||||||
|
return matrix;
|
||||||
|
}, [cursor, value]);
|
||||||
|
|
||||||
|
const prevDisabled = useMemo(() => {
|
||||||
|
const firstDayOfMonth = cursor.startOf('month');
|
||||||
|
return firstDayOfMonth.isSame(DATE_MIN, 'day');
|
||||||
|
}, [cursor]);
|
||||||
|
const nextDisabled = useMemo(() => {
|
||||||
|
const lastDayOfMonth = cursor.endOf('month');
|
||||||
|
return lastDayOfMonth.isSame(DATE_MAX, 'day');
|
||||||
|
}, [cursor]);
|
||||||
|
|
||||||
|
const onNextMonth = useCallback(() => {
|
||||||
|
onCursorChange?.(cursor.add(1, 'month').set('date', 1));
|
||||||
|
}, [cursor, onCursorChange]);
|
||||||
|
const onPrevMonth = useCallback(() => {
|
||||||
|
onCursorChange?.(cursor.add(-1, 'month').set('date', 1));
|
||||||
|
}, [cursor, onCursorChange]);
|
||||||
|
|
||||||
|
const focusCursor = useCallback(() => {
|
||||||
|
const div = dayPickerRootRef.current;
|
||||||
|
if (!div) return;
|
||||||
|
const focused = div.querySelector('[data-is-date-cell][tabindex="0"]');
|
||||||
|
focused && (focused as HTMLElement).focus();
|
||||||
|
}, []);
|
||||||
|
const openMonthPicker = useCallback(
|
||||||
|
() => onModeChange?.('month'),
|
||||||
|
[onModeChange]
|
||||||
|
);
|
||||||
|
const openYearPicker = useCallback(
|
||||||
|
() => onModeChange?.('year'),
|
||||||
|
[onModeChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
const div = dayPickerRootRef.current;
|
||||||
|
if (!div) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const focused = document.activeElement;
|
||||||
|
|
||||||
|
// check if focused is a date cell
|
||||||
|
if (!focused?.hasAttribute('data-is-date-cell')) return;
|
||||||
|
if (e.shiftKey) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp') onCursorChange?.(cursor.add(-7, 'day'));
|
||||||
|
if (e.key === 'ArrowDown') onCursorChange?.(cursor.add(7, 'day'));
|
||||||
|
if (e.key === 'ArrowLeft') onCursorChange?.(cursor.add(-1, 'day'));
|
||||||
|
if (e.key === 'ArrowRight') onCursorChange?.(cursor.add(1, 'day'));
|
||||||
|
setTimeout(focusCursor);
|
||||||
|
};
|
||||||
|
div.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
div?.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [cursor, focusCursor, onCursorChange]);
|
||||||
|
|
||||||
|
const HeaderLeft = useMemo(
|
||||||
|
() => (
|
||||||
|
<div style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={openMonthPicker}
|
||||||
|
ref={headerMonthRef}
|
||||||
|
className={styles.calendarHeaderTriggerButton}
|
||||||
|
>
|
||||||
|
{monthNames.split(',')[cursor.month()]}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.calendarHeaderTriggerButton}
|
||||||
|
onClick={openYearPicker}
|
||||||
|
>
|
||||||
|
{cursor.year()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
[cursor, monthNames, openMonthPicker, openYearPicker]
|
||||||
|
);
|
||||||
|
const HeaderRight = useMemo(
|
||||||
|
() => (
|
||||||
|
<NavButtons
|
||||||
|
key="nav-buttons"
|
||||||
|
onNext={onNextMonth}
|
||||||
|
onPrev={onPrevMonth}
|
||||||
|
prevDisabled={prevDisabled}
|
||||||
|
nextDisabled={nextDisabled}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={styles.headerNavToday}
|
||||||
|
onClick={() => onChange?.(dayjs().format(format))}
|
||||||
|
>
|
||||||
|
{todayLabel}
|
||||||
|
</button>
|
||||||
|
</NavButtons>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
format,
|
||||||
|
nextDisabled,
|
||||||
|
onChange,
|
||||||
|
onNextMonth,
|
||||||
|
onPrevMonth,
|
||||||
|
prevDisabled,
|
||||||
|
todayLabel,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const Body = useMemo(
|
||||||
|
() => (
|
||||||
|
<main className={styles.monthViewBody}>
|
||||||
|
{/* weekDays */}
|
||||||
|
<div className={styles.monthViewRow}>
|
||||||
|
{weekDays.split(',').map(day => (
|
||||||
|
<div key={day} className={styles.monthViewHeaderCell}>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Weeks in month */}
|
||||||
|
{matrix.map((week, i) => {
|
||||||
|
return (
|
||||||
|
<div key={i} className={styles.monthViewRow}>
|
||||||
|
{week.map((cell, j) => (
|
||||||
|
<div
|
||||||
|
className={styles.monthViewBodyCell}
|
||||||
|
key={j}
|
||||||
|
onClick={() => onChange?.(cell.date.format(format))}
|
||||||
|
>
|
||||||
|
{customDayRenderer ? (
|
||||||
|
customDayRenderer(cell)
|
||||||
|
) : (
|
||||||
|
<DefaultDateCell key={j} {...cell} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</main>
|
||||||
|
),
|
||||||
|
[customDayRenderer, format, matrix, onChange, weekDays]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CalendarLayout
|
||||||
|
mode="day"
|
||||||
|
ref={dayPickerRootRef}
|
||||||
|
length={7}
|
||||||
|
headerLeft={HeaderLeft}
|
||||||
|
headerRight={HeaderRight}
|
||||||
|
body={Body}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export function isSameDay(a: dayjs.Dayjs, b: dayjs.Dayjs) {
|
||||||
|
return a.isValid() && b.isValid() && a.isSame(b, 'day');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSameMonth(a: dayjs.Dayjs, b: dayjs.Dayjs) {
|
||||||
|
return a.isValid() && b.isValid() && a.isSame(b, 'month');
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './calendar';
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons';
|
||||||
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
type HTMLAttributes,
|
||||||
|
memo,
|
||||||
|
type PropsWithChildren,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { IconButton } from '../../button';
|
||||||
|
import * as styles from './calendar.css';
|
||||||
|
import type { DateCell } from './types';
|
||||||
|
|
||||||
|
interface HeaderLayoutProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
mode: 'day' | 'month' | 'year';
|
||||||
|
length: number;
|
||||||
|
left: React.ReactNode;
|
||||||
|
right: React.ReactNode;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* The `DatePicker` should work with different width
|
||||||
|
* This is a hack to make header's item align with calendar cell's label, **instead of the cell**
|
||||||
|
* @param length: number of items that calendar body row has
|
||||||
|
*/
|
||||||
|
const HeaderLayout = memo(function HeaderLayout({
|
||||||
|
length,
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
mode,
|
||||||
|
...attrs
|
||||||
|
}: HeaderLayoutProps) {
|
||||||
|
const vars = assignInlineVars({ '--len': `${length}` });
|
||||||
|
const finalStyle = { ...vars, ...style };
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(styles.monthViewRow, className)}
|
||||||
|
style={finalStyle}
|
||||||
|
{...attrs}
|
||||||
|
>
|
||||||
|
{Array.from({ length })
|
||||||
|
.fill(0)
|
||||||
|
.map((_, index) => {
|
||||||
|
const isLeft = index === 0;
|
||||||
|
const isRight = index === length - 1;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
data-length={length}
|
||||||
|
data-is-left={isLeft}
|
||||||
|
data-is-right={isRight}
|
||||||
|
className={clsx({
|
||||||
|
[styles.monthViewBodyCell]: mode === 'day',
|
||||||
|
[styles.yearViewBodyCell]: mode === 'month',
|
||||||
|
[styles.decadeViewBodyCell]: mode === 'year',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className={styles.headerLayoutCellOrigin}>
|
||||||
|
{isLeft ? left : isRight ? right : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface CalendarLayoutProps {
|
||||||
|
headerLeft: ReactNode;
|
||||||
|
headerRight: ReactNode;
|
||||||
|
body: ReactNode;
|
||||||
|
length: number;
|
||||||
|
mode: 'day' | 'month' | 'year';
|
||||||
|
}
|
||||||
|
export const CalendarLayout = forwardRef<HTMLDivElement, CalendarLayoutProps>(
|
||||||
|
(
|
||||||
|
{ headerLeft, headerRight, body, length, mode }: CalendarLayoutProps,
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.calendarWrapper} ref={ref} data-mode={mode}>
|
||||||
|
<HeaderLayout
|
||||||
|
mode={mode}
|
||||||
|
length={length}
|
||||||
|
left={headerLeft}
|
||||||
|
right={headerRight}
|
||||||
|
className={styles.calendarHeader}
|
||||||
|
/>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
CalendarLayout.displayName = 'CalendarLayout';
|
||||||
|
|
||||||
|
export const DefaultDateCell = ({
|
||||||
|
label,
|
||||||
|
date,
|
||||||
|
isToday,
|
||||||
|
notCurrentMonth,
|
||||||
|
selected,
|
||||||
|
focused,
|
||||||
|
}: DateCell) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-is-date-cell
|
||||||
|
data-value={date.format('YYYY-MM-DD')}
|
||||||
|
data-is-today={isToday}
|
||||||
|
data-not-current-month={notCurrentMonth}
|
||||||
|
data-selected={selected}
|
||||||
|
tabIndex={focused ? 0 : -1}
|
||||||
|
className={styles.monthViewBodyCellInner}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface NavButtonsProps extends PropsWithChildren {
|
||||||
|
prevDisabled?: boolean;
|
||||||
|
nextDisabled?: boolean;
|
||||||
|
onPrev?: () => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
}
|
||||||
|
export const NavButtons = memo(function NavButtons({
|
||||||
|
children,
|
||||||
|
prevDisabled,
|
||||||
|
nextDisabled,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
}: NavButtonsProps) {
|
||||||
|
return (
|
||||||
|
<div className={styles.headerNavButtons} key="nav-btn-group">
|
||||||
|
<IconButton
|
||||||
|
key="nav-btn-prev"
|
||||||
|
size="small"
|
||||||
|
className={styles.focusInteractive}
|
||||||
|
disabled={prevDisabled}
|
||||||
|
onClick={onPrev}
|
||||||
|
>
|
||||||
|
<ArrowLeftSmallIcon />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
{children ?? <div className={styles.headerNavGapFallback} />}
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
key="nav-btn-next"
|
||||||
|
size="small"
|
||||||
|
className={styles.focusInteractive}
|
||||||
|
disabled={nextDisabled}
|
||||||
|
onClick={onNext}
|
||||||
|
>
|
||||||
|
<ArrowRightSmallIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './calendar.css';
|
||||||
|
import { DATE_MAX, DATE_MIN } from './constants';
|
||||||
|
import { CalendarLayout, NavButtons } from './items';
|
||||||
|
import type { DatePickerModePanelProps } from './types';
|
||||||
|
|
||||||
|
const ROW_SIZE = 3;
|
||||||
|
|
||||||
|
export const MonthPicker = memo(function MonthPicker(
|
||||||
|
props: DatePickerModePanelProps
|
||||||
|
) {
|
||||||
|
const { cursor, value, monthNames, onModeChange, onCursorChange } = props;
|
||||||
|
const dayPickerRootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [monthCursor, setMonthCursor] = useState(cursor.startOf('month'));
|
||||||
|
|
||||||
|
const closeMonthPicker = useCallback(
|
||||||
|
() => onModeChange?.('day'),
|
||||||
|
[onModeChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onMonthChange = useCallback(
|
||||||
|
(m: dayjs.Dayjs) => {
|
||||||
|
onModeChange?.('day');
|
||||||
|
onCursorChange?.(m);
|
||||||
|
},
|
||||||
|
[onCursorChange, onModeChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextYear = useCallback(
|
||||||
|
() => setMonthCursor(prev => prev.add(1, 'year').startOf('year')),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const prevYear = useCallback(
|
||||||
|
() => setMonthCursor(prev => prev.subtract(1, 'year').startOf('year')),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const nextYearDisabled = useMemo(
|
||||||
|
() => monthCursor.isSame(DATE_MAX, 'year'),
|
||||||
|
[monthCursor]
|
||||||
|
);
|
||||||
|
const prevYearDisabled = useMemo(
|
||||||
|
() => monthCursor.isSame(DATE_MIN, 'year'),
|
||||||
|
[monthCursor]
|
||||||
|
);
|
||||||
|
const matrix = useMemo(() => {
|
||||||
|
const matrix = [];
|
||||||
|
let currentMonth = monthCursor.startOf('year');
|
||||||
|
while (currentMonth.isBefore(monthCursor.endOf('year'))) {
|
||||||
|
const month: DatePickerModePanelProps['cursor'][] = [];
|
||||||
|
for (let i = 0; i < ROW_SIZE; i++) {
|
||||||
|
month.push(currentMonth.clone());
|
||||||
|
currentMonth = currentMonth.add(1, 'month');
|
||||||
|
}
|
||||||
|
matrix.push(month);
|
||||||
|
}
|
||||||
|
return matrix;
|
||||||
|
}, [monthCursor]);
|
||||||
|
|
||||||
|
const focusCursor = useCallback(() => {
|
||||||
|
const div = dayPickerRootRef.current;
|
||||||
|
if (!div) return;
|
||||||
|
const focused = div.querySelector('[data-is-month-cell][tabindex="0"]');
|
||||||
|
focused && (focused as HTMLElement).focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
const div = dayPickerRootRef.current;
|
||||||
|
if (!div) return;
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeMonthPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key))
|
||||||
|
return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp')
|
||||||
|
setMonthCursor(c => c.subtract(ROW_SIZE, 'month'));
|
||||||
|
if (e.key === 'ArrowDown') setMonthCursor(c => c.add(ROW_SIZE, 'month'));
|
||||||
|
if (e.key === 'ArrowLeft') setMonthCursor(c => c.subtract(1, 'month'));
|
||||||
|
if (e.key === 'ArrowRight') setMonthCursor(c => c.add(1, 'month'));
|
||||||
|
setTimeout(focusCursor);
|
||||||
|
};
|
||||||
|
|
||||||
|
div.addEventListener('keydown', onKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
div.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [closeMonthPicker, focusCursor]);
|
||||||
|
|
||||||
|
const HeaderLeft = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={closeMonthPicker}
|
||||||
|
className={styles.calendarHeaderTriggerButton}
|
||||||
|
>
|
||||||
|
{monthCursor.format('YYYY')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}, [closeMonthPicker, monthCursor]);
|
||||||
|
|
||||||
|
const HeaderRight = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<NavButtons
|
||||||
|
onNext={nextYear}
|
||||||
|
onPrev={prevYear}
|
||||||
|
prevDisabled={prevYearDisabled}
|
||||||
|
nextDisabled={nextYearDisabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [nextYear, nextYearDisabled, prevYear, prevYearDisabled]);
|
||||||
|
|
||||||
|
const Body = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<div className={styles.yearViewBody}>
|
||||||
|
{matrix.map((row, i) => {
|
||||||
|
return (
|
||||||
|
<div key={i} className={styles.yearViewRow}>
|
||||||
|
{row.map((month, j) => {
|
||||||
|
return (
|
||||||
|
<div key={j} className={styles.yearViewBodyCell}>
|
||||||
|
<button
|
||||||
|
data-value={month.format('YYYY-MM')}
|
||||||
|
data-is-month-cell
|
||||||
|
className={styles.yearViewBodyCellInner}
|
||||||
|
data-selected={value && month.isSame(value, 'month')}
|
||||||
|
data-current-month={month.isSame(dayjs(), 'month')}
|
||||||
|
onClick={() => onMonthChange(month)}
|
||||||
|
tabIndex={month.isSame(monthCursor, 'month') ? 0 : -1}
|
||||||
|
>
|
||||||
|
{monthNames.split(',')[month.month()]}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [matrix, monthCursor, monthNames, onMonthChange, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CalendarLayout
|
||||||
|
mode="month"
|
||||||
|
ref={dayPickerRootRef}
|
||||||
|
length={3}
|
||||||
|
headerLeft={HeaderLeft}
|
||||||
|
headerRight={HeaderRight}
|
||||||
|
body={Body}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type dayjs from 'dayjs';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface DatePickerProps {
|
||||||
|
/**
|
||||||
|
* selected date value, format is defined by `format` prop
|
||||||
|
*/
|
||||||
|
value?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default 'YYYY-MM-DD'
|
||||||
|
*/
|
||||||
|
format?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customize the vertical gap between each row, in `px`
|
||||||
|
* @default 8
|
||||||
|
*/
|
||||||
|
gapY?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customize the horizontal gap between each column, in `px`
|
||||||
|
* Attention: for responsive layout, this will only affect the minimum gap, the actual gap will be calculated based on the available space
|
||||||
|
* @default 8
|
||||||
|
*/
|
||||||
|
gapX?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customize weekdays, use `,` to separate each day
|
||||||
|
* @default {} `'Su,Mo,Tu,We,Th,Fr,Sa'`
|
||||||
|
**/
|
||||||
|
weekDays?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customize month names
|
||||||
|
*/
|
||||||
|
monthNames?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customize today label
|
||||||
|
*/
|
||||||
|
todayLabel?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customize rendering of day cell
|
||||||
|
*/
|
||||||
|
customDayRenderer?: (cell: DateCell) => ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* when date is clicked
|
||||||
|
*/
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date for a cell in the calendar
|
||||||
|
*/
|
||||||
|
export interface DateCell {
|
||||||
|
date: dayjs.Dayjs;
|
||||||
|
label: string;
|
||||||
|
isToday: boolean;
|
||||||
|
notCurrentMonth: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
focused?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SelectMode = 'day' | 'month' | 'year';
|
||||||
|
|
||||||
|
export const defaultDatePickerProps = {
|
||||||
|
format: 'YYYY-MM-DD',
|
||||||
|
gapX: 8,
|
||||||
|
gapY: 8,
|
||||||
|
weekDays: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].join(','),
|
||||||
|
monthNames: [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
|
].join(','),
|
||||||
|
todayLabel: 'Today',
|
||||||
|
} satisfies Partial<DatePickerProps>;
|
||||||
|
export type DefaultDatePickerProps = typeof defaultDatePickerProps;
|
||||||
|
|
||||||
|
export interface DatePickerModePanelProps
|
||||||
|
extends DefaultDatePickerProps,
|
||||||
|
Omit<DatePickerProps, keyof DefaultDatePickerProps> {
|
||||||
|
cursor: dayjs.Dayjs;
|
||||||
|
onCursorChange?: (cursor: dayjs.Dayjs) => void;
|
||||||
|
onModeChange?: (mode: SelectMode) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './calendar.css';
|
||||||
|
import { DATE_MAX, DATE_MIN, YEAR_MAX, YEAR_MIN } from './constants';
|
||||||
|
import { CalendarLayout, NavButtons } from './items';
|
||||||
|
import type { DatePickerModePanelProps } from './types';
|
||||||
|
|
||||||
|
const ROW_SIZE = 3;
|
||||||
|
const DECADE = 12;
|
||||||
|
|
||||||
|
export const YearPicker = memo(function YearPicker(
|
||||||
|
props: DatePickerModePanelProps
|
||||||
|
) {
|
||||||
|
const { value, cursor, onModeChange, onCursorChange } = props;
|
||||||
|
const dayPickerRootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [yearCursor, setYearCursor] = useState(dayjs(cursor).startOf('year'));
|
||||||
|
|
||||||
|
const closeYearPicker = useCallback(
|
||||||
|
() => onModeChange?.('day'),
|
||||||
|
[onModeChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onYearChange = useCallback(
|
||||||
|
(y: dayjs.Dayjs) => {
|
||||||
|
closeYearPicker();
|
||||||
|
onCursorChange?.(y);
|
||||||
|
},
|
||||||
|
[closeYearPicker, onCursorChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextDecade = useCallback(() => {
|
||||||
|
setYearCursor(prev => prev.add(DECADE, 'year').startOf('year'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const prevDecade = useCallback(() => {
|
||||||
|
setYearCursor(prev => prev.subtract(DECADE, 'year').startOf('year'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const decadeIndex = useMemo(
|
||||||
|
() => Math.floor((yearCursor.year() - YEAR_MIN) / DECADE),
|
||||||
|
[yearCursor]
|
||||||
|
);
|
||||||
|
const decadeStart = useMemo(
|
||||||
|
() => dayjs(DATE_MIN).add(decadeIndex * DECADE, 'year'),
|
||||||
|
[decadeIndex]
|
||||||
|
);
|
||||||
|
const decadeEnd = useMemo(
|
||||||
|
() => decadeStart.add(DECADE - 1, 'year'),
|
||||||
|
[decadeStart]
|
||||||
|
);
|
||||||
|
const nextDecadeDisabled = useMemo(
|
||||||
|
() => yearCursor.add(DECADE, 'year').isAfter(`${YEAR_MAX}-01-01`),
|
||||||
|
[yearCursor]
|
||||||
|
);
|
||||||
|
const prevDecadeDisabled = useMemo(() => decadeIndex <= 0, [decadeIndex]);
|
||||||
|
|
||||||
|
const matrix = useMemo(() => {
|
||||||
|
const matrix = [];
|
||||||
|
|
||||||
|
let currentYear = decadeStart.clone();
|
||||||
|
while (currentYear.isBefore(decadeEnd.add(1, 'year'))) {
|
||||||
|
const row = [];
|
||||||
|
for (let i = 0; i < ROW_SIZE; i++) {
|
||||||
|
row.push(currentYear.clone().startOf('year'));
|
||||||
|
currentYear = currentYear.add(1, 'year');
|
||||||
|
}
|
||||||
|
matrix.push(row);
|
||||||
|
}
|
||||||
|
return matrix;
|
||||||
|
}, [decadeEnd, decadeStart]);
|
||||||
|
|
||||||
|
const focusCursor = useCallback(() => {
|
||||||
|
const div = dayPickerRootRef.current;
|
||||||
|
if (!div) return;
|
||||||
|
const focused = div.querySelector('[data-is-year-cell][tabindex="0"]');
|
||||||
|
focused && (focused as HTMLElement).focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
const div = dayPickerRootRef.current;
|
||||||
|
if (!div) return;
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeYearPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key))
|
||||||
|
return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp') setYearCursor(c => c.subtract(ROW_SIZE, 'year'));
|
||||||
|
if (e.key === 'ArrowDown') setYearCursor(c => c.add(ROW_SIZE, 'year'));
|
||||||
|
if (e.key === 'ArrowLeft') setYearCursor(c => c.subtract(1, 'year'));
|
||||||
|
if (e.key === 'ArrowRight') setYearCursor(c => c.add(1, 'year'));
|
||||||
|
setTimeout(focusCursor);
|
||||||
|
};
|
||||||
|
|
||||||
|
div.addEventListener('keydown', onKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
div.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [closeYearPicker, focusCursor]);
|
||||||
|
|
||||||
|
const HeaderLeft = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={closeYearPicker}
|
||||||
|
className={styles.calendarHeaderTriggerButton}
|
||||||
|
>
|
||||||
|
{decadeStart.year()}-{decadeEnd.year()}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}, [closeYearPicker, decadeEnd, decadeStart]);
|
||||||
|
const HeaderRight = useMemo(
|
||||||
|
() => (
|
||||||
|
<NavButtons
|
||||||
|
onNext={nextDecade}
|
||||||
|
onPrev={prevDecade}
|
||||||
|
nextDisabled={nextDecadeDisabled}
|
||||||
|
prevDisabled={prevDecadeDisabled}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[nextDecade, nextDecadeDisabled, prevDecade, prevDecadeDisabled]
|
||||||
|
);
|
||||||
|
const Body = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<div className={styles.decadeViewBody}>
|
||||||
|
{matrix.map((row, i) => {
|
||||||
|
return (
|
||||||
|
<div key={i} className={styles.decadeViewRow}>
|
||||||
|
{row.map((year, j) => {
|
||||||
|
const isDisabled =
|
||||||
|
year.isAfter(DATE_MAX) || year.isBefore(DATE_MIN);
|
||||||
|
return (
|
||||||
|
<div key={j} className={styles.decadeViewBodyCell}>
|
||||||
|
<button
|
||||||
|
aria-disabled={isDisabled}
|
||||||
|
data-value={year.format('YYYY')}
|
||||||
|
data-is-year-cell
|
||||||
|
className={styles.decadeViewBodyCellInner}
|
||||||
|
data-selected={value && year.isSame(value, 'year')}
|
||||||
|
data-current-year={year.isSame(dayjs(), 'year')}
|
||||||
|
tabIndex={year.isSame(yearCursor, 'year') ? 0 : -1}
|
||||||
|
onClick={
|
||||||
|
isDisabled ? undefined : () => onYearChange(year)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{year.year()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [matrix, onYearChange, value, yearCursor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CalendarLayout
|
||||||
|
mode="year"
|
||||||
|
ref={dayPickerRootRef}
|
||||||
|
length={ROW_SIZE}
|
||||||
|
headerLeft={HeaderLeft}
|
||||||
|
headerRight={HeaderRight}
|
||||||
|
body={Body}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './date-picker';
|
export * from './affine-date-picker';
|
||||||
|
export * from './calendar';
|
||||||
export * from './week-date-picker';
|
export * from './week-date-picker';
|
||||||
|
|||||||
@@ -1064,6 +1064,9 @@
|
|||||||
"com.affine.calendar.weekdays.thu": "Thu",
|
"com.affine.calendar.weekdays.thu": "Thu",
|
||||||
"com.affine.calendar.weekdays.fri": "Fri",
|
"com.affine.calendar.weekdays.fri": "Fri",
|
||||||
"com.affine.calendar.weekdays.sat": "Sat",
|
"com.affine.calendar.weekdays.sat": "Sat",
|
||||||
|
"com.affine.calendar-date-picker.week-days": "Su,Mo,Tu,We,Th,Fr,Sa",
|
||||||
|
"com.affine.calendar-date-picker.today": "Today",
|
||||||
|
"com.affine.calendar-date-picker.month-names": "Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec",
|
||||||
"com.affine.journal.created-today": "Created today",
|
"com.affine.journal.created-today": "Created today",
|
||||||
"com.affine.journal.updated-today": "Updated today",
|
"com.affine.journal.updated-today": "Updated today",
|
||||||
"com.affine.journal.daily-count-created-empty-tips": "You haven't created anything yet",
|
"com.affine.journal.daily-count-created-empty-tips": "You haven't created anything yet",
|
||||||
|
|||||||
@@ -975,5 +975,8 @@
|
|||||||
"system": "跟随系统",
|
"system": "跟随系统",
|
||||||
"upgradeBrowser": "请升级至最新版Chrome浏览器以获得最佳体验。",
|
"upgradeBrowser": "请升级至最新版Chrome浏览器以获得最佳体验。",
|
||||||
"will be moved to Trash": "{{title}} 将被移到垃圾箱",
|
"will be moved to Trash": "{{title}} 将被移到垃圾箱",
|
||||||
"will delete member": "将删除成员"
|
"will delete member": "将删除成员",
|
||||||
|
"com.affine.calendar-date-picker.week-days": "日,一,二,三,四,五,六",
|
||||||
|
"com.affine.calendar-date-picker.today": "今天",
|
||||||
|
"com.affine.calendar-date-picker.month-names": "一月,二月,三月,四月,五月,六月,七月,八月,九月,十月,十一月,十二月"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user