diff --git a/packages/smarthr-ui/src/components/Calendar/Calendar.tsx b/packages/smarthr-ui/src/components/Calendar/Calendar.tsx index 3fc4529410..661a03d609 100644 --- a/packages/smarthr-ui/src/components/Calendar/Calendar.tsx +++ b/packages/smarthr-ui/src/components/Calendar/Calendar.tsx @@ -4,7 +4,9 @@ import dayjs from 'dayjs' import React, { ComponentProps, MouseEvent, + PropsWithChildren, forwardRef, + useCallback, useEffect, useId, useMemo, @@ -13,13 +15,12 @@ import React, { import { tv } from 'tailwind-variants' import { Button } from '../Button' -import { FaCaretDownIcon, FaCaretUpIcon, FaChevronLeftIcon, FaChevronRightIcon } from '../Icon' +import { FaCaretDownIcon, FaChevronLeftIcon, FaChevronRightIcon } from '../Icon' import { Cluster } from '../Layout' -import { Section } from '../SectioningContent' import { CalendarTable } from './CalendarTable' import { YearPicker } from './YearPicker' -import { getFromDate, getToDate, isBetween, minDate } from './calendarHelper' +import { getFromDate, getMonthArray, getToDate, isBetween, minDate } from './calendarHelper' type Props = { /** 選択可能な開始日 */ @@ -31,7 +32,8 @@ type Props = { /** 選択された日付 */ value?: Date } -type ElementProps = Omit, keyof Props> +type ElementProps = Omit, keyof Props> +type DayJsType = ReturnType const calendar = tv({ slots: { @@ -41,108 +43,195 @@ const calendar = tv({ yearMonth: 'smarthr-ui-Calendar-yearMonth shr-me-0.5 shr-text-base shr-font-bold', monthButtons: 'smarthr-ui-Calendar-monthButtons shr-ms-auto shr-flex', tableLayout: 'shr-relative', + yearSelectButton: + 'smarthr-ui-Calendar-selectingYear [&[aria-expanded="true"]_.smarthr-ui-Icon]:shr-rotate-180', }, }) export const Calendar = forwardRef( ({ from = minDate, to, onSelectDate, value, className, ...props }, ref) => { - const { containerStyle, yearMonthStyle, headerStyle, monthButtonsStyle, tableLayoutStyle } = - useMemo(() => { - const { container, yearMonth, header, monthButtons, tableLayout } = calendar() - return { - containerStyle: container({ className }), - headerStyle: header(), - yearMonthStyle: yearMonth(), - monthButtonsStyle: monthButtons(), - tableLayoutStyle: tableLayout(), + const classNames = useMemo(() => { + const { container, yearMonth, header, monthButtons, tableLayout, yearSelectButton } = + calendar() + + return { + container: container({ className }), + header: header(), + yearMonth: yearMonth(), + monthButtons: monthButtons(), + tableLayout: tableLayout(), + yearSelectButton: yearSelectButton(), + } + }, [className]) + + const formattedFrom = useMemo(() => { + const date = getFromDate(from) + const day = dayjs(date) + + return { + day, + date, + year: day.year(), + } + }, [from]) + const formattedTo = useMemo(() => { + const date = getToDate(to) + const day = dayjs(date) + + return { + day, + date, + year: day.year(), + } + }, [to]) + + const isValidValue = useMemo( + () => value && isBetween(value, formattedFrom.date, formattedTo.date), + [value, formattedFrom.date, formattedTo.date], + ) + + const [currentMonth, setCurrentMonth] = useState( + (() => { + if (isValidValue) { + return dayjs(value) } - }, [className]) - const fromDate = dayjs(getFromDate(from)) - const toDate = dayjs(getToDate(to)) - const today = dayjs() - const currentDay = toDate.isBefore(today) ? toDate : fromDate.isAfter(today) ? fromDate : today - const isValidValue = value && isBetween(value, fromDate.toDate(), toDate.toDate()) - - const [currentMonth, setCurrentMonth] = useState(isValidValue ? dayjs(value) : currentDay) + + const today = dayjs() + + return formattedTo.day.isBefore(today) + ? formattedTo.day + : formattedFrom.day.isAfter(today) + ? formattedFrom.day + : today + })(), + ) const [isSelectingYear, setIsSelectingYear] = useState(false) const yearPickerId = useId() useEffect(() => { - if (value && isValidValue) { + if (isValidValue) { setCurrentMonth(dayjs(value)) } }, [value, isValidValue]) - const prevMonth = currentMonth.subtract(1, 'month') - const nextMonth = currentMonth.add(1, 'month') + const calculatedCurrentMonth = useMemo( + () => ({ + prev: currentMonth.subtract(1, 'month'), + next: currentMonth.add(1, 'month'), + day: currentMonth, + months: getMonthArray(currentMonth.toDate()), + yearMonthStr: `${currentMonth.year()}年${currentMonth.month() + 1}月`, + selectedStr: currentMonth.toString(), + }), + [currentMonth], + ) + + const onSelectYear = useCallback( + (e: React.MouseEvent) => { + setCurrentMonth(currentMonth.year(parseInt(e.currentTarget.value, 10))) + setIsSelectingYear(false) + }, + [currentMonth], + ) + + const onClickSelectYear = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + setIsSelectingYear((current) => !current) + }, []) return ( - // eslint-disable-next-line smarthr/a11y-heading-in-sectioning-content -
-
-
- {currentMonth.year()}年{currentMonth.month() + 1}月 -
- - - - - + onClick={onClickSelectYear} + className={classNames.yearSelectButton} + /> +
-
+
{ - setCurrentMonth(currentMonth.year(year)) - setIsSelectingYear(false) - }} + onSelectYear={onSelectYear} isDisplayed={isSelectingYear} id={yearPickerId} />
-
+ ) }, ) + +const YearMonthRender = React.memo>( + ({ children, className }) =>
{children}
, +) + +const YearSelectButton = React.memo<{ + 'aria-expanded': boolean + 'aria-controls': string + onClick: (e: React.MouseEvent) => void + className: string +}>((rest) => ( + +)) + +const MonthDirectionCluster = React.memo<{ + isSelectingYear: boolean + directionMonth: { + prev: DayJsType + next: DayJsType + } + from: DayJsType + to: DayJsType + setCurrentMonth: (day: DayJsType) => void + className: string +}>(({ isSelectingYear, directionMonth: { prev, next }, from, to, setCurrentMonth, className }) => { + const onClickMonthPrev = useCallback(() => setCurrentMonth(prev), [prev, setCurrentMonth]) + const onClickMonthNext = useCallback(() => setCurrentMonth(next), [next, setCurrentMonth]) + + return ( + + + + + ) +}) diff --git a/packages/smarthr-ui/src/components/Calendar/CalendarTable.tsx b/packages/smarthr-ui/src/components/Calendar/CalendarTable.tsx index 782dae22ba..6e61e89f1e 100644 --- a/packages/smarthr-ui/src/components/Calendar/CalendarTable.tsx +++ b/packages/smarthr-ui/src/components/Calendar/CalendarTable.tsx @@ -1,14 +1,17 @@ import dayjs from 'dayjs' -import React, { ComponentPropsWithoutRef, FC, MouseEvent, useMemo } from 'react' +import React, { ComponentPropsWithoutRef, FC, MouseEvent, useCallback, useMemo } from 'react' import { tv } from 'tailwind-variants' import { UnstyledButton } from '../Button' -import { daysInWeek, getMonthArray, isBetween } from './calendarHelper' +import { daysInWeek, isBetween } from './calendarHelper' type Props = { /** 現在の日付 */ - current: Date + current: { + day: DayJsType + months: Array> + } /** 選択可能な開始日 */ from: Date /** 選択可能な終了日 */ @@ -16,10 +19,12 @@ type Props = { /** トリガのセレクトイベントを処理するハンドラ */ onSelectDate: (e: MouseEvent, date: Date) => void /** 選択された日付 */ - selected?: Date | null + selectedDayStr: string } type ElementProps = Omit, keyof Props> +type DayJsType = ReturnType + const calendarTable = tv({ slots: { wrapper: 'shr-px-0.75 shr-pb-1 shr-pt-0.25', @@ -28,20 +33,11 @@ const calendarTable = tv({ td: 'smarthr-ui-CalendarTable-dataCell shr-p-0 shr-align-middle', cellButton: 'shr-group shr-flex shr-items-center shr-justify-center shr-px-0.5 shr-py-0.25 disabled:shr-cursor-not-allowed disabled:shr-text-disabled', - dateCell: + dateCell: [ 'shr-box-border shr-flex shr-h-[1.75rem] shr-w-[1.75rem] shr-items-center shr-justify-center shr-rounded-[50%] shr-leading-[0] group-[:not(:disabled)]:group-hover:shr-bg-base-grey group-[:not(:disabled)]:group-hover:shr-text-black', - }, - variants: { - isToday: { - true: { - dateCell: 'shr-border-shorthand contrast-more:shr-border-high-contrast', - }, - }, - isSelected: { - true: { - dateCell: '[&&&&]:shr-bg-main [&&&&]:shr-text-white', - }, - }, + '[[aria-pressed="true"]>&&&]:shr-bg-main [[aria-pressed="true"]>&&&]:shr-text-white', + '[[data-is-today="true"]>&&&]:shr-border-shorthand [[aria-pressed="true"]>&&&]:contrast-more:shr-border-high-contrast', + ], }, }) @@ -50,75 +46,50 @@ export const CalendarTable: FC = ({ from, to, onSelectDate, - selected, + selectedDayStr, className, ...props }) => { - const { wrapper, table, th, td, cellButton, dateCell } = calendarTable() - const { wrapperStyle, tableStyle, thStyle, tdStyle, cellButtonStyle } = useMemo( - () => ({ - wrapperStyle: wrapper({ className }), - tableStyle: table(), - thStyle: th(), - tdStyle: td(), - cellButtonStyle: cellButton(), - }), - [cellButton, className, table, td, th, wrapper], - ) - const currentDay = dayjs(current) - const selectedDay = selected ? dayjs(selected) : null + const classNames = useMemo(() => { + const { wrapper, table, th, td, cellButton, dateCell } = calendarTable() + + return { + wrapper: wrapper({ className }), + table: table(), + th: th(), + td: td(), + cellButton: cellButton(), + dateCell: dateCell(), + } + }, [className]) - const now = dayjs().startOf('date') - const fromDay = dayjs(from) - const toDay = dayjs(to) + // HINT: dayjsのisSameは文字列でも比較可能なため、cacheが効きやすいstringにする + const nowDateStr = dayjs().startOf('date').toString() - const array = getMonthArray(currentDay.toDate()) return ( -
- - - - {daysInWeek.map((day, i) => ( - - ))} - - +
+
- {day} -
+ - {array.map((week, weekIndex) => ( + {current.months.map((week, weekIndex) => ( - {week.map((date, dateIndex) => { - const isOutRange = - !date || - !isBetween(currentDay.date(date).toDate(), fromDay.toDate(), toDay.toDate()) - const isSelectedDate = - !!date && !!selectedDay && currentDay.date(date).isSame(selectedDay, 'date') - return ( - - ) - })} + {week.map((date, dateIndex) => + date ? ( + + ) : ( + + ), + )} ))} @@ -126,3 +97,73 @@ export const CalendarTable: FC = ({ ) } + +const MemoizedThead = React.memo<{ thStyle: string }>(({ thStyle }) => ( + + + {daysInWeek.map((day) => ( + + ))} + + +)) + +const NullTd = React.memo<{ className: string }>(({ className }) => + ) +}) + +const SelectButtonTdDateCell = React.memo<{ children: number; className: string }>( + ({ children, className }) => {children}, +) diff --git a/packages/smarthr-ui/src/components/Calendar/YearPicker.tsx b/packages/smarthr-ui/src/components/Calendar/YearPicker.tsx index f0c1363745..724113787c 100644 --- a/packages/smarthr-ui/src/components/Calendar/YearPicker.tsx +++ b/packages/smarthr-ui/src/components/Calendar/YearPicker.tsx @@ -1,9 +1,9 @@ -import React, { ComponentProps, FC, useEffect, useMemo, useRef } from 'react' +import React, { ComponentProps, FC, RefObject, useEffect, useMemo, useRef } from 'react' import { tv } from 'tailwind-variants' import { UnstyledButton } from '../Button' -type Props = { +type AbstractProps = { /** 選択された年 */ selectedYear?: number /** 選択可能な開始年 */ @@ -11,13 +11,15 @@ type Props = { /** 選択可能な終了年 */ toYear: number /** トリガのセレクトイベントを処理するハンドラ */ - onSelectYear: (year: number) => void + onSelectYear: (e: React.MouseEvent) => void /** 表示フラグ */ isDisplayed: boolean /** HTMLのid属性 */ id: string } -type ElementProps = Omit, keyof Props> +type ElementProps = Omit, keyof AbstractProps> +type Props = AbstractProps & ElementProps +type ActualProps = Omit const yearPicker = tv({ slots: { @@ -26,82 +28,99 @@ const yearPicker = tv({ 'shr-box-border shr-flex shr-h-full shr-w-full shr-flex-wrap shr-items-start shr-overflow-y-auto shr-px-0.25 shr-py-0.5', yearButton: 'smarthr-ui-YearPicker-selectYear shr-group shr-flex shr-w-1/4 shr-items-center shr-justify-center shr-px-0 shr-py-0.5 shr-leading-none', - yearWrapper: + yearWrapper: [ 'shr-box-border shr-inline-block shr-rounded-full shr-px-0.75 shr-py-0.5 shr-text-base shr-leading-none group-hover:shr-bg-base-grey group-hover:shr-text-black', - }, - variants: { - isDisplayed: { - false: { - overlay: 'shr-hidden', - }, - }, - isThisYear: { - true: { - yearWrapper: 'shr-border-shorthand', - }, - }, - isSelected: { - true: { - yearWrapper: 'shr-bg-main shr-text-white', - }, - }, + '[[data-this-year="true"]>&]:shr-border-shorthand', + '[[aria-pressed="true"]>&]:shr-bg-main [[aria-pressed="true"]>&]:shr-text-white', + ], }, }) -export const YearPicker: FC = ({ +export const YearPicker: FC = ({ isDisplayed, ...rest }) => + isDisplayed ? : null + +const ActualYearPicker: FC = ({ selectedYear, fromYear, toYear, onSelectYear, - isDisplayed, id, ...props }) => { - const { overlay, container, yearButton, yearWrapper } = yearPicker() - const { overlayStyle, containerStyle, yearButtonStyle } = useMemo( - () => ({ - overlayStyle: overlay({ isDisplayed }), - containerStyle: container(), - yearButtonStyle: yearButton(), - }), - [container, isDisplayed, overlay, yearButton], - ) + const classNames = useMemo(() => { + const { overlay, container, yearButton, yearWrapper } = yearPicker() + + return { + overlay: overlay(), + container: container(), + yearButton: yearButton(), + yearWrapper: yearWrapper(), + } + }, []) const focusingRef = useRef(null) - const thisYear = new Date().getFullYear() - const numOfYear = Math.max(Math.min(toYear, 9999) - fromYear + 1, 0) - const yearArray = Array(numOfYear) - .fill(null) - .map((_, i) => fromYear + i) + const thisYear = useMemo(() => new Date().getFullYear(), []) + const yearArray = useMemo(() => { + const length = Math.max(Math.min(toYear, 9999) - fromYear + 1, 0) + const result: number[] = [] + + for (let i = 0; i < length; i++) { + result[i] = fromYear + i + } + + return result + }, [toYear, fromYear]) useEffect(() => { - if (focusingRef.current && isDisplayed) { + if (focusingRef.current) { + // HINT: 現在の年に一度focusを当てることでtab移動をしやすくする + // focusを当てたままでは違和感があるため、blurで解除している focusingRef.current.focus() focusingRef.current.blur() } - }, [isDisplayed]) + }, []) return ( -
-
- {yearArray.map((year) => { - const isThisYear = thisYear === year - const isSelectedYear = selectedYear === year - return ( - onSelectYear(year)} - aria-pressed={isSelectedYear} - ref={isThisYear ? focusingRef : null} - className={yearButtonStyle} - > - - {year} - - - ) - })} +
+
+ {yearArray.map((year) => ( + + ))}
) } + +const YearButton = React.memo<{ + year: number + thisYear: number + selected: boolean + focusingRef: RefObject + className: string + childrenStyle: string + onClick: (e: React.MouseEvent) => void +}>(({ year, thisYear, selected, focusingRef, onClick, className, childrenStyle }) => { + const isThisYear = thisYear === year + + return ( + + {year} + + ) +}) diff --git a/packages/smarthr-ui/src/components/Calendar/calendarHelper.ts b/packages/smarthr-ui/src/components/Calendar/calendarHelper.ts index e81d97caf1..fd9b28d6d9 100644 --- a/packages/smarthr-ui/src/components/Calendar/calendarHelper.ts +++ b/packages/smarthr-ui/src/components/Calendar/calendarHelper.ts @@ -31,20 +31,29 @@ export function getToDate(date?: Date): Date { } export function getMonthArray(date: Date) { - const startDay = dayjs(date).date(1).day() - const lastDate = dayjs(date).add(1, 'month').date(0).date() + const day = dayjs(date) + const startDay = day.date(1).day() + const lastDate = day.add(1, 'month').date(0).date() const numOfWeek = Math.ceil((lastDate + startDay) / 7) - return Array.from({ length: numOfWeek }).map((_, weekIndex) => { + const result: Array> = [] + + for (let weekIndex = 0; weekIndex < numOfWeek; weekIndex++) { // 週毎の配列を形成 const startDateInWeek = weekIndex * 7 - startDay + 1 - return Array.from({ length: 7 }).map((__, dateIndex) => { + const weekNumbers: Array = [] + + for (let dateIndex = 0; dateIndex < 7; dateIndex++) { // 1週の配列を形成 const dateNum = startDateInWeek + dateIndex - return dateNum > 0 && dateNum <= lastDate ? dateNum : null - }) - }) + weekNumbers[dateIndex] = dateNum > 0 && dateNum <= lastDate ? dateNum : null + } + + result[weekIndex] = weekNumbers + } + + return result } export function isBetween(date: Date, from: Date, to: Date) {
- {date && ( - - !isOutRange && onSelectDate(e, currentDay.date(date).toDate()) - } - aria-pressed={isSelectedDate} - type="button" - className={cellButtonStyle} - > - - {date} - - - )} -
+ {day} +
) + +const SelectTdButton = React.memo<{ + date: number + currentDay: DayJsType + selectedDayStr: string + from: Date + to: Date + nowDateStr: string + onClick: Props['onSelectDate'] + classNames: { + td: string + cellButton: string + dateCell: string + } +}>(({ date, currentDay, selectedDayStr, from, to, nowDateStr, onClick, classNames }) => { + const target = useMemo(() => { + const day = currentDay.date(date) + + return { + day, + date: day.toDate(), + } + }, [currentDay, date]) + const disabled = useMemo(() => !isBetween(target.date, from, to), [target.date, from, to]) + const ariaPressed = useMemo( + () => target.day.isSame(selectedDayStr, 'date'), + [selectedDayStr, target.day], + ) + const dataIsToday = useMemo(() => target.day.isSame(nowDateStr, 'date'), [nowDateStr, target.day]) + + const actualOnClick = useCallback( + (e: React.MouseEvent) => { + onClick(e, target.date) + }, + [onClick, target.date], + ) + + return ( + + + {date} + +