diff --git a/packages/frontend/component/src/ui/date-picker/date-picker.stories.tsx b/packages/frontend/component/src/ui/date-picker/affine-date-picker.stories.tsx similarity index 95% rename from packages/frontend/component/src/ui/date-picker/date-picker.stories.tsx rename to packages/frontend/component/src/ui/date-picker/affine-date-picker.stories.tsx index dd652b5ea4..9e3287497f 100644 --- a/packages/frontend/component/src/ui/date-picker/date-picker.stories.tsx +++ b/packages/frontend/component/src/ui/date-picker/affine-date-picker.stories.tsx @@ -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; const _format = 'YYYY-MM-DD'; diff --git a/packages/frontend/component/src/ui/date-picker/date-picker.tsx b/packages/frontend/component/src/ui/date-picker/affine-date-picker.tsx similarity index 100% rename from packages/frontend/component/src/ui/date-picker/date-picker.tsx rename to packages/frontend/component/src/ui/date-picker/affine-date-picker.tsx diff --git a/packages/frontend/component/src/ui/date-picker/calendar/calendar.css.ts b/packages/frontend/component/src/ui/date-picker/calendar/calendar.css.ts new file mode 100644 index 0000000000..f7d0a2f328 --- /dev/null +++ b/packages/frontend/component/src/ui/date-picker/calendar/calendar.css.ts @@ -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]); diff --git a/packages/frontend/component/src/ui/date-picker/calendar/calendar.stories.tsx b/packages/frontend/component/src/ui/date-picker/calendar/calendar.stories.tsx new file mode 100644 index 0000000000..f6e179d167 --- /dev/null +++ b/packages/frontend/component/src/ui/date-picker/calendar/calendar.stories.tsx @@ -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; + +const _format = 'YYYY-MM-DD'; + +const Template: StoryFn = args => { + const [date, setDate] = useState(dayjs().format(_format)); + return ( +
+
Selected Date: {date}
+ + + + +
+ ); +}; + +export const Basic: StoryFn = 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', +}; diff --git a/packages/frontend/component/src/ui/date-picker/calendar/calendar.tsx b/packages/frontend/component/src/ui/date-picker/calendar/calendar.tsx new file mode 100644 index 0000000000..51bfb169fb --- /dev/null +++ b/packages/frontend/component/src/ui/date-picker/calendar/calendar.tsx @@ -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('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 ( +
+ +
+ ); +}; diff --git a/packages/frontend/component/src/ui/date-picker/calendar/constants.ts b/packages/frontend/component/src/ui/date-picker/calendar/constants.ts new file mode 100644 index 0000000000..2b0c8736dd --- /dev/null +++ b/packages/frontend/component/src/ui/date-picker/calendar/constants.ts @@ -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(); diff --git a/packages/frontend/component/src/ui/date-picker/calendar/day-picker.tsx b/packages/frontend/component/src/ui/date-picker/calendar/day-picker.tsx new file mode 100644 index 0000000000..a13dd7f794 --- /dev/null +++ b/packages/frontend/component/src/ui/date-picker/calendar/day-picker.tsx @@ -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(null); + const headerMonthRef = useRef(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( + () => ( +
+ + +
+ ), + [cursor, monthNames, openMonthPicker, openYearPicker] + ); + const HeaderRight = useMemo( + () => ( + + + + ), + [ + format, + nextDisabled, + onChange, + onNextMonth, + onPrevMonth, + prevDisabled, + todayLabel, + ] + ); + const Body = useMemo( + () => ( +
+ {/* weekDays */} +
+ {weekDays.split(',').map(day => ( +
+ {day} +
+ ))} +
+ {/* Weeks in month */} + {matrix.map((week, i) => { + return ( +
+ {week.map((cell, j) => ( +
onChange?.(cell.date.format(format))} + > + {customDayRenderer ? ( + customDayRenderer(cell) + ) : ( + + )} +
+ ))} +
+ ); + })} +
+ ), + [customDayRenderer, format, matrix, onChange, weekDays] + ); + + return ( + + ); +}); diff --git a/packages/frontend/component/src/ui/date-picker/calendar/helpers.ts b/packages/frontend/component/src/ui/date-picker/calendar/helpers.ts new file mode 100644 index 0000000000..908c61adb5 --- /dev/null +++ b/packages/frontend/component/src/ui/date-picker/calendar/helpers.ts @@ -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'); +} diff --git a/packages/frontend/component/src/ui/date-picker/calendar/index.ts b/packages/frontend/component/src/ui/date-picker/calendar/index.ts new file mode 100644 index 0000000000..edaf8f07ab --- /dev/null +++ b/packages/frontend/component/src/ui/date-picker/calendar/index.ts @@ -0,0 +1 @@ +export * from './calendar'; diff --git a/packages/frontend/component/src/ui/date-picker/calendar/items.tsx b/packages/frontend/component/src/ui/date-picker/calendar/items.tsx new file mode 100644 index 0000000000..133ed9724a --- /dev/null +++ b/packages/frontend/component/src/ui/date-picker/calendar/items.tsx @@ -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 { + 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 ( +
+ {Array.from({ length }) + .fill(0) + .map((_, index) => { + const isLeft = index === 0; + const isRight = index === length - 1; + return ( +
+
+ {isLeft ? left : isRight ? right : null} +
+
+ ); + })} +
+ ); +}); + +interface CalendarLayoutProps { + headerLeft: ReactNode; + headerRight: ReactNode; + body: ReactNode; + length: number; + mode: 'day' | 'month' | 'year'; +} +export const CalendarLayout = forwardRef( + ( + { headerLeft, headerRight, body, length, mode }: CalendarLayoutProps, + ref + ) => { + return ( +
+ + {body} +
+ ); + } +); +CalendarLayout.displayName = 'CalendarLayout'; + +export const DefaultDateCell = ({ + label, + date, + isToday, + notCurrentMonth, + selected, + focused, +}: DateCell) => { + return ( + + ); +}; + +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 ( +
+ + + + + {children ??
} + + + + +
+ ); +}); diff --git a/packages/frontend/component/src/ui/date-picker/calendar/month-picker.tsx b/packages/frontend/component/src/ui/date-picker/calendar/month-picker.tsx new file mode 100644 index 0000000000..6502fbf93f --- /dev/null +++ b/packages/frontend/component/src/ui/date-picker/calendar/month-picker.tsx @@ -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(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 ( + + ); + }, [closeMonthPicker, monthCursor]); + + const HeaderRight = useMemo(() => { + return ( + + ); + }, [nextYear, nextYearDisabled, prevYear, prevYearDisabled]); + + const Body = useMemo(() => { + return ( +
+ {matrix.map((row, i) => { + return ( +
+ {row.map((month, j) => { + return ( +
+ +
+ ); + })} +
+ ); + })} +
+ ); + }, [matrix, monthCursor, monthNames, onMonthChange, value]); + + return ( + + ); +}); diff --git a/packages/frontend/component/src/ui/date-picker/calendar/types.tsx b/packages/frontend/component/src/ui/date-picker/calendar/types.tsx new file mode 100644 index 0000000000..895205d6d7 --- /dev/null +++ b/packages/frontend/component/src/ui/date-picker/calendar/types.tsx @@ -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; +export type DefaultDatePickerProps = typeof defaultDatePickerProps; + +export interface DatePickerModePanelProps + extends DefaultDatePickerProps, + Omit { + cursor: dayjs.Dayjs; + onCursorChange?: (cursor: dayjs.Dayjs) => void; + onModeChange?: (mode: SelectMode) => void; +} diff --git a/packages/frontend/component/src/ui/date-picker/calendar/year-picker.tsx b/packages/frontend/component/src/ui/date-picker/calendar/year-picker.tsx new file mode 100644 index 0000000000..dc3479c083 --- /dev/null +++ b/packages/frontend/component/src/ui/date-picker/calendar/year-picker.tsx @@ -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(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 ( + + ); + }, [closeYearPicker, decadeEnd, decadeStart]); + const HeaderRight = useMemo( + () => ( + + ), + [nextDecade, nextDecadeDisabled, prevDecade, prevDecadeDisabled] + ); + const Body = useMemo(() => { + return ( +
+ {matrix.map((row, i) => { + return ( +
+ {row.map((year, j) => { + const isDisabled = + year.isAfter(DATE_MAX) || year.isBefore(DATE_MIN); + return ( +
+ +
+ ); + })} +
+ ); + })} +
+ ); + }, [matrix, onYearChange, value, yearCursor]); + + return ( + + ); +}); diff --git a/packages/frontend/component/src/ui/date-picker/index.ts b/packages/frontend/component/src/ui/date-picker/index.ts index 691bfd7ada..816711aede 100644 --- a/packages/frontend/component/src/ui/date-picker/index.ts +++ b/packages/frontend/component/src/ui/date-picker/index.ts @@ -1,2 +1,3 @@ -export * from './date-picker'; +export * from './affine-date-picker'; +export * from './calendar'; export * from './week-date-picker'; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index fa347e0ef1..19e3ca2c1f 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -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", diff --git a/packages/frontend/i18n/src/resources/zh-Hans.json b/packages/frontend/i18n/src/resources/zh-Hans.json index 4a02b375d9..49b8ccd23e 100644 --- a/packages/frontend/i18n/src/resources/zh-Hans.json +++ b/packages/frontend/i18n/src/resources/zh-Hans.json @@ -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": "一月,二月,三月,四月,五月,六月,七月,八月,九月,十月,十一月,十二月" }