refactor(component): new calendar-view DatePicker (#5654)

This commit is contained in:
Cats Juice 2024-02-20 13:53:36 +00:00
parent 876b85304e
commit e664494b2f
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
16 changed files with 1184 additions and 3 deletions

View File

@ -5,7 +5,7 @@ import { useState } from 'react';
import { AFFiNEDatePicker } from '.';
export default {
title: 'UI/Date Picker/Date Picker',
title: 'UI/Date Picker/AFFiNE Date Picker',
} satisfies Meta<typeof AFFiNEDatePicker>;
const _format = 'YYYY-MM-DD';

View File

@ -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]);

View File

@ -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',
};

View File

@ -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>
);
};

View File

@ -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();

View File

@ -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}
/>
);
});

View File

@ -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');
}

View File

@ -0,0 +1 @@
export * from './calendar';

View File

@ -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>
);
});

View File

@ -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}
/>
);
});

View File

@ -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;
}

View File

@ -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}
/>
);
});

View File

@ -1,2 +1,3 @@
export * from './date-picker';
export * from './affine-date-picker';
export * from './calendar';
export * from './week-date-picker';

View File

@ -1064,6 +1064,9 @@
"com.affine.calendar.weekdays.thu": "Thu",
"com.affine.calendar.weekdays.fri": "Fri",
"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.updated-today": "Updated today",
"com.affine.journal.daily-count-created-empty-tips": "You haven't created anything yet",

View File

@ -975,5 +975,8 @@
"system": "跟随系统",
"upgradeBrowser": "请升级至最新版Chrome浏览器以获得最佳体验。",
"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": "一月,二月,三月,四月,五月,六月,七月,八月,九月,十月,十一月,十二月"
}