Skip to content

Instantly share code, notes, and snippets.

@hamza512b
Created February 1, 2022 05:16
Show Gist options
  • Save hamza512b/c84a01c15cbc521b2ded1f15c1bd99a2 to your computer and use it in GitHub Desktop.
Save hamza512b/c84a01c15cbc521b2ded1f15c1bd99a2 to your computer and use it in GitHub Desktop.
Accessible date picker

Accessible date picker

It is not mobile responsive though, the button need to be bigger and the dialog needs to fill the whole screen.

The component may not have everything covered. But this demonstrates how much code is required for implementing an accessible component. Here, it is around 200 lines, but keep in mind that I relied heavily on external packages.

Here is the demo

import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from "react";
import {
useFocusTrap,
useKeyboardAction,
useOutsideClick
} from "react-access-hooks";
import ChevronRightIcon from "@heroicons/react/outline/ChevronRightIcon";
import ChevronDoubleRightIcon from "@heroicons/react/outline/ChevronDoubleRightIcon";
import ChevronLeftIcon from "@heroicons/react/outline/ChevronLeftIcon";
import ChevronDoubleLeftIcon from "@heroicons/react/outline/ChevronDoubleLeftIcon";
import CalendarIcon from "@heroicons/react/solid/CalendarIcon";
import { DateTime } from "luxon";
import { Button } from "./button";
export interface DatePickerProps {
date?: Date;
onChange?: (date: Date) => void;
label?: string;
labelClasses?: string;
}
let listbox_id = -1;
export function DatePicker(props: DatePickerProps): React.ReactElement {
const [defaultDate, setDefaultDate] = useState<Date>(
props.date || new Date()
);
const [open, setOpen] = useState(false);
const [date, setDate] = useState<Date>(defaultDate);
const id = useMemo(() => (listbox_id += 1), []);
const getWeeks: () => DateTime[][] = useCallback(() => {
const luxonDate = DateTime.fromJSDate(date);
return Array(6)
.fill(Array(7).fill(0))
.map((week, i) =>
week.map((_: number, j: number) =>
luxonDate
.startOf("month")
.startOf("week")
.plus({ days: 7 * i + j })
)
);
}, [date]);
function save(dateArg: Date) {
setDate(dateArg);
setDefaultDate(dateArg);
setOpen(false);
if (props.onChange) props.onChange(dateArg);
}
function cancel() {
setDate(defaultDate);
setOpen(false);
}
const datePickerDialogRef = useRef(null);
const selectedItemRef = useRef<HTMLButtonElement>(null);
const datePickerGridRef = useRef(null);
useFocusTrap({
element: datePickerDialogRef,
condition: open,
initialFocus: selectedItemRef
});
useOutsideClick({
element: datePickerDialogRef,
condition: open,
action: () => cancel()
});
useKeyboardAction({
key: "Escape",
action: () => cancel(),
condition: open,
target: window
});
useKeyboardAction({
key: "ArrowUp",
action: () => {
setDate(DateTime.fromJSDate(date).minus({ weeks: 1 }).toJSDate());
},
target: datePickerGridRef.current
});
useKeyboardAction({
key: "ArrowDown",
action: () => {
setDate(DateTime.fromJSDate(date).plus({ weeks: 1 }).toJSDate());
},
target: datePickerGridRef.current
});
useKeyboardAction({
key: "ArrowLeft",
action: () => {
setDate(DateTime.fromJSDate(date).minus({ days: 1 }).toJSDate());
},
target: datePickerGridRef.current
});
useKeyboardAction({
key: "ArrowRight",
action: () => {
setDate(DateTime.fromJSDate(date).plus({ days: 1 }).toJSDate());
},
target: datePickerGridRef.current
});
useKeyboardAction({
key: "Home",
action: () => {
setDate(DateTime.fromJSDate(date).startOf("week").toJSDate());
},
target: datePickerGridRef.current
});
useKeyboardAction({
key: "End",
action: () => {
setDate(DateTime.fromJSDate(date).endOf("week").toJSDate());
},
target: datePickerGridRef.current
});
useKeyboardAction({
key: "PageUp",
action: () => {
setDate(DateTime.fromJSDate(date).minus({ months: 1 }).toJSDate());
},
target: datePickerGridRef.current
});
useKeyboardAction({
key: "PageDown",
action: () => {
setDate(DateTime.fromJSDate(date).plus({ months: 1 }).toJSDate());
},
target: datePickerGridRef.current
});
useEffect(() => {
if (selectedItemRef.current) selectedItemRef.current?.focus();
});
return (
<div className="max-w-xs relative">
{props.label && (
<label
htmlFor={`datepicker-label-${id}`}
className={`font-semibold text-black-4 ${props.labelClasses}`}
onClick={() => setOpen(true)}
>
{props.label}
</label>
)}
<button
type="button"
onClick={() => setOpen(true)}
aria-label={`Date picker button ${DateTime.fromJSDate(
date
).toISODate()}`}
className="border border-white-1 bg-white px-3 py-2 text-black-4 rounded-md w-full flex justify-between items-center focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-3 shadow-sm"
>
<span id={`datepicker-label-${id}`}>
{DateTime.fromJSDate(date).toISODate()}
</span>
<CalendarIcon className="w-5 h-5" />
</button>
<div
className={`bg-white w-full max-w-[280px] absolute transform translate-y-1 shadow-md border border-white-1 rounded-md ${
!open ? "hidden" : ""
}`}
ref={datePickerDialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={`dialog-label-${id}`}
>
<div className="flex justify-between py-0.5 px-1">
<div className="flex text-black-4">
<button
type={"button"}
onClick={() =>
setDate(
DateTime.fromJSDate(date).minus({ years: 1 }).toJSDate()
)
}
aria-label="Previous Year"
>
<ChevronDoubleLeftIcon className="w-[26px] h-[26px]" />
</button>
<button
type={"button"}
onClick={() =>
setDate(
DateTime.fromJSDate(date).minus({ months: 1 }).toJSDate()
)
}
aria-label="Previous Month"
>
<ChevronLeftIcon className="w-[26px] h-[26px]" />
</button>
</div>
<h2
id={`dialog-label-${id}`}
aria-live="polite"
className="text-lg text-black-1 font-semibold"
>
{DateTime.fromJSDate(date).toFormat("LLLL yyyy")}
</h2>
<div className="flex text-black-4">
<button
type={"button"}
onClick={() =>
setDate(
DateTime.fromJSDate(date).plus({ months: 1 }).toJSDate()
)
}
aria-label="Next Month"
>
<ChevronRightIcon className="w-[26px] h-[26px]" />
</button>
<button
type={"button"}
onClick={() =>
setDate(DateTime.fromJSDate(date).plus({ years: 1 }).toJSDate())
}
aria-label="Next Year"
>
<ChevronDoubleRightIcon className="w-[26px] h-[26px]" />
</button>
</div>
</div>
<table
ref={datePickerGridRef}
role="grid"
aria-labelledby={`dialog-label-${id}`}
className="w-full"
>
<thead>
<tr className="flex justify-between items-center">
{Array(7)
.fill(0)
.map((_, i) => {
const luxonTime = DateTime.fromJSDate(date)
.startOf("week")
.plus({ days: i });
return (
<th
key={i}
scope="col"
abbr="Monday"
className="w-full h-10 grid place-content-center text-xs"
>
{luxonTime.toFormat("ccc")}
</th>
);
})}
</tr>
</thead>
<tbody>
{getWeeks().map((week, index) => (
<tr key={index} className="flex justify-between items-center">
{week.map((dayDate, index) => {
const luxonDate = DateTime.fromJSDate(date);
const focus = luxonDate.toISODate() === dayDate.toISODate();
const inactive =
luxonDate.startOf("month").toMillis() >
dayDate.toMillis() ||
luxonDate.endOf("month").toMillis() < dayDate.toMillis();
return (
<td
key={index}
className={`relative w-full h-10 text-xs rounded-[10px] cursor-pointer ${
inactive
? "text-black-4/40"
: "focus-within:ring-black focus-within:ring-2 hover:bg-blue-2 hover:text-white-4"
} ${focus ? "bg-blue-2 text-white-4" : ""}`}
>
<button
key={index}
className={`absolute inset-0 w-full h-full focus:outline-none text-center font-semibold ${
focus ? "active" : ""
}`}
tabIndex={focus ? 0 : -1}
ref={focus ? selectedItemRef : undefined}
onClick={() => save(dayDate.toJSDate())}
disabled={inactive}
type={"button"}
>
{dayDate.day}
</button>
</td>
);
})}
</tr>
))}
</tbody>
</table>
<div className="float-right space-x-2 p-1">
<Button type={"button"} onClick={cancel}>
Reset
</Button>
<Button type={"button"} onClick={() => save(date)}>
Save
</Button>
</div>
</div>
</div>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment