/*
* 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
};
}
|