Skip to content

Commit

Permalink
Improve accessibility for DatePicker (#793)
Browse files Browse the repository at this point in the history
## Background

There are some accessibility issues with the DatePicker (and consequently the DateRangePicker):
- One cannot tab inside the calendars, which makes it impossible for keyboard users to change months/years using the arrow buttons.
- One cannot navigate inside the calendars with screen reader at all (using ctrl+opt+arrow) in, e.g., Chrome.
- When closing the calendars, the focus ends up in a totally different place than what is logical.
- There is no visual focus when moving focus to today's date.
- The language codes are not totally correct, which causes some screen reader text to be read in English while other in Norwegian.

Other issues:
- It appears a little buggy that one cannot close the calendar using the button, which is possible in [React Aria's example](https://react-spectrum.adobe.com/react-aria/useDatePicker.html).

## Solution

- Make it possible to navigate with keyboard in the calendar:

    First, study React Aria's example to see if there are any props we are missing:
      - `hidden={isOutsideVisibleRange}` helps navigate in the calendar with tab.

    Secondly, study Entur's DatePicker, who also use the same library as us:
      - To be able to tab easily inside the DatePicker, we can use a `FocusLock` around the `Calendar`, since we here really do not want to end up outside of the calendar unexpectedly.

- Raise this [issue](adobe/react-spectrum#4922) in the `react-spectrum` repo. They made a [workaround](adobe/react-spectrum#4931) we await release on, while the original [bug](https://bugs.chromium.org/p/chromium/issues/detail?id=1473752) is reported to Chrome.

- Make sure focus is reset to the calendar button when closed. This way, the next focusable element will be the next logical element. The way this is achieved, is wrapping the `CalendarTriggerButton` in a `PopoverTrigger`, which handles this for us. When using a PopoverTrigger, the focusable child must have a `forwarded ref`. The button then also needs the date picker `ref` instead of the input.

- Add visual focus to today's date in the calendar when it is focused.

- Correct the language codes used in React Aria's `useState` so that everything announced by the screen reader is in the same and correct language.

- For the bugginess around closing the calendar with the button: 
    This is a little hard nut because we here are using the Popover from Chakra and not from React Aria. If we simply had used the Popover from React Aria, the open-close would handle itself the correct way. We use this Popover because we use the `PopoverAnchor`, `PopoverPopoverArrow`, an so on from Chakra, which must be wrapped in the Chakra Popover. 

   Then, we basically have two states, one for the Popover and another for the DatePicker (via `useDatePicker's state`). The Popover listens to outside clicks, escape clicks and so on, and the Popover can fire `onOpen` and `onClose` callbacks based on this. Here, we update the DatePicker's state to open and closed. If we did not do it this way, the first focusable element within the calendar is not set correctly (the first one should be today's date). 
   
   The reason why the calendar closes and opens right away when clicking the trigger button while the calendar is open, is then because the Popover notices we press outside the calendar and closes the state, while the trigger button then opens it again since the state just got closed.

  This is also fixed by using the PopoverTrigger. The other props, `returnFocusOnClose` etc., specified for the Popover is really then not needed since they did not work as intended anyway.

- In addition to this:
  Since we had problems with portals for the ComboBox, add `withPortal` as an optional prop so that using a Portal or not is optional.
  

Will then also solve #731.
  • Loading branch information
heisand authored Aug 24, 2023
1 parent 4b86c8b commit 160c056
Show file tree
Hide file tree
Showing 9 changed files with 137 additions and 101 deletions.
5 changes: 5 additions & 0 deletions .changeset/serious-pillows-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@vygruppen/spor-react": patch
---

Improve accessibility for DatePicker
17 changes: 16 additions & 1 deletion packages/spor-react/src/datepicker/Calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import {
import { CalendarGrid } from "./CalendarGrid";
import { CalendarHeader } from "./CalendarHeader";
import { useCurrentLocale } from "./utils";
import { createTexts, useTranslation } from "../i18n";

type CalendarProps = ReactAriaCalendarProps<DateValue> & {
showYearNavigation?: boolean;
};
export function Calendar({ showYearNavigation, ...props }: CalendarProps) {
const { t } = useTranslation();
const locale = useCurrentLocale();
const state = useCalendarState({
...props,
Expand All @@ -22,11 +24,24 @@ export function Calendar({ showYearNavigation, ...props }: CalendarProps) {
});

const { calendarProps } = useCalendar(props, state);
const calendarAriaLabel = calendarProps["aria-label"];

const ariaLabel =
t(texts.calendar) + (calendarAriaLabel ? ` ${calendarAriaLabel}` : "");

return (
<Box {...calendarProps}>
<Box {...calendarProps} aria-label={ariaLabel}>
<CalendarHeader state={state} showYearNavigation={showYearNavigation} />
<CalendarGrid state={state} />
</Box>
);
}

const texts = createTexts({
calendar: {
nb: "Kalender",
nn: "Kalender",
sv: "Kalender",
en: "Calendar",
},
});
13 changes: 10 additions & 3 deletions packages/spor-react/src/datepicker/CalendarCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ type CalendarCellProps = {
};
export function CalendarCell({ state, date, currentMonth }: CalendarCellProps) {
const ref = useRef(null);
const { cellProps, buttonProps, isSelected, isDisabled, isUnavailable } =
useCalendarCell({ date }, state, ref);
const {
cellProps,
buttonProps,
isSelected,
isDisabled,
isUnavailable,
isOutsideVisibleRange,
} = useCalendarCell({ date }, state, ref);

const isOutsideMonth = !isSameMonth(currentMonth, date);
const styles = useMultiStyleConfig("Datepicker", {});
Expand All @@ -35,7 +41,7 @@ export function CalendarCell({ state, date, currentMonth }: CalendarCellProps) {
if (isOutsideMonth) {
stateProps["data-unavailable"] = true;
}

return (
<Box
as="td"
Expand Down Expand Up @@ -65,6 +71,7 @@ export function CalendarCell({ state, date, currentMonth }: CalendarCellProps) {
{...stateProps}
ref={ref}
sx={styles.dateCell}
hidden={isOutsideVisibleRange}
width="100%"
>
{date.day}
Expand Down
52 changes: 29 additions & 23 deletions packages/spor-react/src/datepicker/CalendarTriggerButton.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,36 @@
import { Box, PopoverAnchor, useMultiStyleConfig } from "@chakra-ui/react";
import {
Box,
PopoverAnchor,
useMultiStyleConfig,
forwardRef,
As,
} from "@chakra-ui/react";
import { CalendarOutline24Icon } from "@vygruppen/spor-icon-react";
import React, { useRef } from "react";
import { AriaButtonProps, useButton } from "react-aria";
import React from "react";
import { AriaButtonProps } from "react-aria";
import { createTexts, useTranslation } from "..";

type CalendarTriggerButtonProps = AriaButtonProps<"button">;
export const CalendarTriggerButton = (props: CalendarTriggerButtonProps) => {
const { t } = useTranslation();
const styles = useMultiStyleConfig("Datepicker", {});
const ref = useRef(null);
const { buttonProps } = useButton(props, ref);
return (
<PopoverAnchor>
<Box
ref={ref}
as="button"
type="button"
aria-label={t(texts.openCalendar)}
sx={styles.calendarTriggerButton}
{...(buttonProps as any)}
>
<CalendarOutline24Icon />
</Box>
</PopoverAnchor>
);
};
export const CalendarTriggerButton = forwardRef<CalendarTriggerButtonProps, As>(
({ ...buttonProps }, ref) => {
const { t } = useTranslation();
const styles = useMultiStyleConfig("Datepicker", {});

return (
<PopoverAnchor>
<Box
ref={ref}
as="button"
aria-label={t(texts.openCalendar)}
sx={styles.calendarTriggerButton}
{...buttonProps}
>
<CalendarOutline24Icon />
</Box>
</PopoverAnchor>
);
}
);

const texts = createTexts({
openCalendar: {
Expand Down
78 changes: 37 additions & 41 deletions packages/spor-react/src/datepicker/DatePicker.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {
Box,
BoxProps,
FocusLock,
InputGroup,
Popover,
PopoverAnchor,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
Portal,
ResponsiveValue,
useBreakpointValue,
Expand All @@ -17,7 +19,12 @@ import { DateValue } from "@internationalized/date";
import { useDatePickerState } from "@react-stately/datepicker";
import { CalendarOutline24Icon } from "@vygruppen/spor-icon-react";
import React, { forwardRef, useRef } from "react";
import { AriaDatePickerProps, I18nProvider, useDatePicker } from "react-aria";
import {
AriaDatePickerProps,
CalendarProps,
I18nProvider,
useDatePicker,
} from "react-aria";
import { FormErrorMessage } from "..";
import { Calendar } from "./Calendar";
import { CalendarTriggerButton } from "./CalendarTriggerButton";
Expand All @@ -30,6 +37,7 @@ type DatePickerProps = AriaDatePickerProps<DateValue> &
variant: ResponsiveValue<"simple" | "with-trigger">;
name?: string;
showYearNavigation?: boolean;
withPortal?: boolean;
};
/**
* A date picker component.
Expand All @@ -47,6 +55,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
errorMessage,
minHeight,
showYearNavigation,
withPortal = true,
width = "auto",
...props
},
Expand Down Expand Up @@ -76,33 +85,33 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
ref as React.MutableRefObject<HTMLDivElement>
);

const styles = useMultiStyleConfig("Datepicker", {});
const locale = useCurrentLocale();

const responsiveVariant =
useBreakpointValue(typeof variant === "string" ? [variant] : variant) ??
"simple";

const locale = useCurrentLocale();

const handleEnterClick = (e: React.KeyboardEvent) => {
if (
responsiveVariant === "simple" &&
e.key === "Enter" &&
!state.isOpen
) {
// Don't submit the form
e.stopPropagation();
state.setOpen(true);
}
};
const hasTrigger = responsiveVariant === "with-trigger";

const onFieldClick = () => {
if (!hasTrigger) {
state.setOpen(true);
}
};

const hasTrigger = responsiveVariant === "with-trigger";

const styles = useMultiStyleConfig("Datepicker", {});
const popoverContent = (
<PopoverContent color="darkGrey" boxShadow="md" sx={styles.calendar}>
<PopoverArrow sx={styles.arrow} />
<PopoverBody>
<FocusLock>
<Calendar
{...calendarProps}
showYearNavigation={showYearNavigation}
/>
</FocusLock>
</PopoverBody>
</PopoverContent>
);

return (
<I18nProvider locale={locale}>
Expand All @@ -115,18 +124,14 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
<Popover
{...dialogProps}
isOpen={state.isOpen}
onClose={state.close}
onOpen={state.open}
closeOnBlur
closeOnEsc
returnFocusOnClose
onClose={state.close}
>
<InputGroup {...groupProps} display="inline-flex">
<PopoverAnchor>
<StyledField
variant={responsiveVariant}
onClick={onFieldClick}
onKeyPress={handleEnterClick}
paddingX={3}
minHeight={minHeight}
>
Expand All @@ -137,33 +142,24 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
label={props.label}
labelProps={labelProps}
name={props.name}
ref={ref}
ref={hasTrigger ? undefined : ref}
{...fieldProps}
/>
</StyledField>
</PopoverAnchor>
{hasTrigger && <CalendarTriggerButton {...buttonProps} />}
{hasTrigger && (
<PopoverTrigger>
<CalendarTriggerButton ref={ref} {...buttonProps} />
</PopoverTrigger>
)}
</InputGroup>
<FormErrorMessage {...errorMessageProps}>
{errorMessage}
</FormErrorMessage>
{state.isOpen && !props.isDisabled && (
<Portal>
<PopoverContent
color="darkGrey"
boxShadow="md"
sx={styles.calendar}
>
<PopoverArrow sx={styles.arrow} />
<PopoverBody>
<Calendar
{...calendarProps}
showYearNavigation={showYearNavigation}
/>
</PopoverBody>
</PopoverContent>
</Portal>
{state.isOpen && !props.isDisabled && withPortal && (
<Portal>{popoverContent}</Portal>
)}
{state.isOpen && !props.isDisabled && !withPortal && popoverContent}
</Popover>
</Box>
</I18nProvider>
Expand Down
Loading

0 comments on commit 160c056

Please sign in to comment.