mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-11-29 15:52:45 +03:00
refactor(component): new calendar-view DatePicker (#5654)
This commit is contained in:
parent
876b85304e
commit
e664494b2f
@ -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';
|
@ -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';
|
||||
|
@ -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",
|
||||
|
@ -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": "一月,二月,三月,四月,五月,六月,七月,八月,九月,十月,十一月,十二月"
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user