/* * Copyright 2020 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ import {CalendarDate, isEqualDay, isSameDay, isToday} from '@internationalized/date'; import {CalendarState, RangeCalendarState} from '@react-stately/calendar'; import {DOMAttributes, RefObject} from '@react-types/shared'; import {focusWithoutScrolling, getActiveElement, getEventTarget, getScrollParent, mergeProps, scrollIntoViewport, useDeepMemo, useDescription} from '@react-aria/utils'; import {getEraFormat, hookData} from './utils'; import {getInteractionModality, usePress} from '@react-aria/interactions'; // @ts-ignore import intlMessages from '../intl/*.json'; import {useDateFormatter, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useEffect, useMemo, useRef} from 'react'; export interface AriaCalendarCellProps { /** The date that this cell represents. */ date: CalendarDate, /** * Whether the cell is disabled. By default, this is determined by the * Calendar's `minValue`, `maxValue`, and `isDisabled` props. */ isDisabled?: boolean, /** * Whether the cell is outside of the current month. */ isOutsideMonth?: boolean } export interface CalendarCellAria { /** Props for the grid cell element (e.g. ``). */ cellProps: DOMAttributes, /** Props for the button element within the cell. */ buttonProps: DOMAttributes, /** Whether the cell is currently being pressed. */ isPressed: boolean, /** Whether the cell is selected. */ isSelected: boolean, /** Whether the cell is focused. */ isFocused: boolean, /** * Whether the cell is disabled, according to the calendar's `minValue`, `maxValue`, and `isDisabled` props. * Disabled dates are not focusable, and cannot be selected by the user. They are typically * displayed with a dimmed appearance. */ isDisabled: boolean, /** * Whether the cell is unavailable, according to the calendar's `isDateUnavailable` prop. Unavailable dates remain * focusable, but cannot be selected by the user. They should be displayed with a visual affordance to indicate they * are unavailable, such as a different color or a strikethrough. * * Note that because they are focusable, unavailable dates must meet a 4.5:1 color contrast ratio, * [as defined by WCAG](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html). */ isUnavailable: boolean, /** * Whether the cell is outside the visible range of the calendar. * For example, dates before the first day of a month in the same week. */ isOutsideVisibleRange: boolean, /** Whether the cell is part of an invalid selection. */ isInvalid: boolean, /** The day number formatted according to the current locale. */ formattedDate: string } /** * Provides the behavior and accessibility implementation for a calendar cell component. * A calendar cell displays a date cell within a calendar grid which can be selected by the user. */ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarState | RangeCalendarState, ref: RefObject): CalendarCellAria { let {date, isDisabled} = props; let {errorMessageId, selectedDateDescription} = hookData.get(state)!; let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/calendar'); let dateFormatter = useDateFormatter({ weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', era: getEraFormat(date), timeZone: state.timeZone }); let isSelected = state.isSelected(date); let isFocused = state.isCellFocused(date) && !props.isOutsideMonth; isDisabled = isDisabled || state.isCellDisabled(date); let isUnavailable = state.isCellUnavailable(date); let isSelectable = !isDisabled && !isUnavailable; let isInvalid = state.isValueInvalid && Boolean( 'highlightedRange' in state ? !state.anchorDate && state.highlightedRange && date.compare(state.highlightedRange.start) >= 0 && date.compare(state.highlightedRange.end) <= 0 : state.value && isSameDay(state.value, date) ); if (isInvalid) { isSelected = true; } // For performance, reuse the same date object as before if the new date prop is the same. // This allows subsequent useMemo results to be reused. date = useDeepMemo(date, isEqualDay); let nativeDate = useMemo(() => date.toDate(state.timeZone), [date, state.timeZone]); // aria-label should be localize Day of week, Month, Day and Year without Time. let isDateToday = isToday(date, state.timeZone); let label = useMemo(() => { let label = ''; // If this is a range calendar, add a description of the full selected range // to the first and last selected date. if ( 'highlightedRange' in state && state.value && !state.anchorDate && (isSameDay(date, state.value.start) || isSameDay(date, state.value.end)) ) { label = selectedDateDescription + ', '; } label += dateFormatter.format(nativeDate); if (isDateToday) { // If date is today, set appropriate string depending on selected state: label = stringFormatter.format(isSelected ? 'todayDateSelected' : 'todayDate', { date: label }); } else if (isSelected) { // If date is selected but not today: label = stringFormatter.format('dateSelected', { date: label }); } if (state.minValue && isSameDay(date, state.minValue)) { label += ', ' + stringFormatter.format('minimumDate'); } else if (state.maxValue && isSameDay(date, state.maxValue)) { label += ', ' + stringFormatter.format('maximumDate'); } return label; }, [dateFormatter, nativeDate, stringFormatter, isSelected, isDateToday, date, state, selectedDateDescription]); // When a cell is focused and this is a range calendar, add a prompt to help // screenreader users know that they are in a range selection mode. let rangeSelectionPrompt = ''; if ('anchorDate' in state && isFocused && !state.isReadOnly && isSelectable) { // If selection has started add "click to finish selecting range" if (state.anchorDate) { rangeSelectionPrompt = stringFormatter.format('finishRangeSelectionPrompt'); // Otherwise, add "click to start selecting range" prompt } else { rangeSelectionPrompt = stringFormatter.format('startRangeSelectionPrompt'); } } let descriptionProps = useDescription(rangeSelectionPrompt); let isAnchorPressed = useRef(false); let isRangeBoundaryPressed = useRef(false); let touchDragTimerRef = useRef | undefined>(undefined); let {pressProps, isPressed} = usePress({ // When dragging to select a range, we don't want dragging over the original anchor // again to trigger onPressStart. Cancel presses immediately when the pointer exits. shouldCancelOnPointerExit: 'anchorDate' in state && !!state.anchorDate, preventFocusOnPress: true, isDisabled: !isSelectable || state.isReadOnly, onPressStart(e) { if (state.isReadOnly) { state.setFocusedDate(date); state.setFocused(true); return; } if ('highlightedRange' in state && !state.anchorDate && (e.pointerType === 'mouse' || e.pointerType === 'touch')) { // Allow dragging the start or end date of a range to modify it // rather than starting a new selection. // Don't allow dragging when invalid, or weird jumping behavior may occur as date ranges // are constrained to available dates. The user will need to select a new range in this case. if (state.highlightedRange && !isInvalid) { if (isSameDay(date, state.highlightedRange.start)) { state.setAnchorDate(state.highlightedRange.end); state.setFocusedDate(date); state.setFocused(true); state.setDragging(true); isRangeBoundaryPressed.current = true; return; } else if (isSameDay(date, state.highlightedRange.end)) { state.setAnchorDate(state.highlightedRange.start); state.setFocusedDate(date); state.setFocused(true); state.setDragging(true); isRangeBoundaryPressed.current = true; return; } } let startDragging = () => { state.setDragging(true); touchDragTimerRef.current = undefined; state.selectDate(date); state.setFocusedDate(date); state.setFocused(true); isAnchorPressed.current = true; }; // Start selection on mouse/touch down so users can drag to select a range. // On touch, delay dragging to determine if the user really meant to scroll. if (e.pointerType === 'touch') { touchDragTimerRef.current = setTimeout(startDragging, 200); } else { startDragging(); } } }, onPressEnd() { isRangeBoundaryPressed.current = false; isAnchorPressed.current = false; clearTimeout(touchDragTimerRef.current); touchDragTimerRef.current = undefined; }, onPress() { // For non-range selection, always select on press up. if (!('anchorDate' in state) && !state.isReadOnly) { state.selectDate(date); state.setFocusedDate(date); state.setFocused(true); } }, onPressUp(e) { if (state.isReadOnly) { return; } // If the user tapped quickly, the date won't be selected yet and the // timer will still be in progress. In this case, select the date on touch up. // Timer is cleared in onPressEnd. if ('anchorDate' in state && touchDragTimerRef.current) { state.selectDate(date); state.setFocusedDate(date); state.setFocused(true); } if ('anchorDate' in state) { if (isRangeBoundaryPressed.current) { // When clicking on the start or end date of an already selected range, // start a new selection on press up to also allow dragging the date to // change the existing range. state.setAnchorDate(date); } else if (state.anchorDate && !isAnchorPressed.current) { // When releasing a drag or pressing the end date of a range, select it. state.selectDate(date); state.setFocusedDate(date); state.setFocused(true); } else if (e.pointerType === 'keyboard' && !state.anchorDate) { // For range selection, auto-advance the focused date by one if using keyboard. // This gives an indication that you're selecting a range rather than a single date. // For mouse, this is unnecessary because users will see the indication on hover. For screen readers, // there will be an announcement to "click to finish selecting range" (above). state.selectDate(date); let nextDay = date.add({days: 1}); if (state.isInvalid(nextDay)) { nextDay = date.subtract({days: 1}); } if (!state.isInvalid(nextDay)) { state.setFocusedDate(nextDay); state.setFocused(true); } } else if (e.pointerType === 'virtual') { // For screen readers, just select the date on click. state.selectDate(date); state.setFocusedDate(date); state.setFocused(true); } } } }); let tabIndex: number | undefined = undefined; if (!isDisabled) { tabIndex = isSameDay(date, state.focusedDate) ? 0 : -1; } // Focus the button in the DOM when the state updates. useEffect(() => { if (isFocused && ref.current) { focusWithoutScrolling(ref.current); // Scroll into view if navigating with a keyboard, otherwise // try not to shift the view under the user's mouse/finger. // If in a overlay, scrollIntoViewport will only cause scrolling // up to the overlay scroll body to prevent overlay shifting. // Also only scroll into view if the cell actually got focused. // There are some cases where the cell might be disabled or inside, // an inert container and we don't want to scroll then. if (getInteractionModality() !== 'pointer' && getActiveElement() === ref.current) { scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)}); } } }, [isFocused, ref]); let cellDateFormatter = useDateFormatter({ day: 'numeric', timeZone: state.timeZone, calendar: date.calendar.identifier }); let formattedDate = useMemo(() => cellDateFormatter.formatToParts(nativeDate).find(part => part.type === 'day')!.value, [cellDateFormatter, nativeDate]); return { cellProps: { role: 'gridcell', 'aria-disabled': !isSelectable || undefined, 'aria-selected': isSelected || undefined, 'aria-invalid': isInvalid || undefined }, buttonProps: mergeProps(pressProps, { onFocus() { if (!isDisabled) { state.setFocusedDate(date); state.setFocused(true); } }, tabIndex, role: 'button', 'aria-disabled': !isSelectable || undefined, 'aria-label': label, 'aria-invalid': isInvalid || undefined, 'aria-describedby': [ isInvalid ? errorMessageId : undefined, descriptionProps['aria-describedby'] ].filter(Boolean).join(' ') || undefined, onPointerEnter(e) { // Highlight the date on hover or drag over a date when selecting a range. if ('highlightDate' in state && (e.pointerType !== 'touch' || state.isDragging) && isSelectable) { state.highlightDate(date); } }, onPointerDown(e: PointerEvent) { // This is necessary on touch devices to allow dragging // outside the original pressed element. // (JSDOM does not support this) let target = getEventTarget(e); if (target instanceof HTMLElement && 'releasePointerCapture' in target) { if ('hasPointerCapture' in target) { if (target.hasPointerCapture(e.pointerId)) { target.releasePointerCapture(e.pointerId); } } else { (target as HTMLElement).releasePointerCapture(e.pointerId); } } }, onContextMenu(e) { // Prevent context menu on long press. e.preventDefault(); } }), isPressed, isFocused, isSelected, isDisabled, isUnavailable, isOutsideVisibleRange: date.compare(state.visibleRange.start) < 0 || date.compare(state.visibleRange.end) > 0, isInvalid, formattedDate }; }