fix(date-picker): improve calendar accessibility

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-03-24 18:00:20 +08:00
parent 397ef7ace9
commit 3c172c411e
3 changed files with 25 additions and 15 deletions
@@ -3,6 +3,12 @@ import { describe, expect, it, vi } from "vitest";
import { DatePicker } from "./date-picker"; import { DatePicker } from "./date-picker";
function getCalendarDayButtons(dialogName: string) {
return within(screen.getByRole("dialog", { name: dialogName }))
.getAllByRole("button")
.filter((button) => button.getAttribute("data-slot") === "day");
}
describe("DatePicker", () => { describe("DatePicker", () => {
it("renders a placeholder and selects a date in uncontrolled mode", async () => { it("renders a placeholder and selects a date in uncontrolled mode", async () => {
render( render(
@@ -16,8 +22,7 @@ describe("DatePicker", () => {
const field = screen.getByRole("combobox", { name: "Launch date" }); const field = screen.getByRole("combobox", { name: "Launch date" });
expect(field.closest('[data-slot="root"]')).toHaveAttribute("data-placeholder", ""); expect(field.closest('[data-slot="root"]')).toHaveAttribute("data-placeholder", "");
const calendar = screen.getByRole("grid"); const dayButton = getCalendarDayButtons("Launch date calendar")[10];
const dayButton = within(calendar).getAllByRole("gridcell")[10];
fireEvent.click(dayButton); fireEvent.click(dayButton);
@@ -38,7 +43,7 @@ describe("DatePicker", () => {
); );
fireEvent.click( fireEvent.click(
screen.getByRole("gridcell", { screen.getByRole("button", {
name: /Apr 20, 2026|20 Apr 2026|Apr 20 2026/i name: /Apr 20, 2026|20 Apr 2026|Apr 20 2026/i
}) })
); );
@@ -96,7 +101,7 @@ describe("DatePicker", () => {
/> />
); );
const firstDay = within(screen.getByRole("grid")).getAllByRole("gridcell")[0]; const firstDay = getCalendarDayButtons("Monday-first date calendar")[0];
expect(firstDay).toHaveAccessibleName("Feb 23, 2026"); expect(firstDay).toHaveAccessibleName("Feb 23, 2026");
expect(screen.getByText("Mon")).toBeInTheDocument(); expect(screen.getByText("Mon")).toBeInTheDocument();
@@ -113,7 +118,7 @@ describe("DatePicker", () => {
/> />
); );
const firstDay = within(screen.getByRole("grid")).getAllByRole("gridcell")[0]; const firstDay = getCalendarDayButtons("Sunday-first date calendar")[0];
expect(firstDay).toHaveAccessibleName("Mar 1, 2026"); expect(firstDay).toHaveAccessibleName("Mar 1, 2026");
expect(screen.getByText("Sun")).toBeInTheDocument(); expect(screen.getByText("Sun")).toBeInTheDocument();
@@ -130,9 +135,9 @@ describe("DatePicker", () => {
/> />
); );
const disabledDays = screen const disabledDays = getCalendarDayButtons("Guardrailed date calendar").filter((cell) =>
.getAllByRole("gridcell") cell.hasAttribute("data-disabled")
.filter((cell) => cell.hasAttribute("data-disabled")); );
expect(disabledDays.length).toBeGreaterThan(0); expect(disabledDays.length).toBeGreaterThan(0);
}); });
+9 -4
View File
@@ -423,7 +423,13 @@ export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function
</div> </div>
</PopoverAnchor> </PopoverAnchor>
<PopoverContent className={datePickerContentVariants()} id={popupId} padding="sm" size="xl"> <PopoverContent
aria-label={`${props["aria-label"] ?? "Date picker"} calendar`}
className={datePickerContentVariants()}
id={popupId}
padding="sm"
size="xl"
>
<div className="grid gap-3"> <div className="grid gap-3">
<div {...createSlot("header")} className={datePickerHeaderVariants()}> <div {...createSlot("header")} className={datePickerHeaderVariants()}>
<div className={datePickerNavigationVariants()}> <div className={datePickerNavigationVariants()}>
@@ -510,7 +516,7 @@ export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function
))} ))}
</div> </div>
<div className={datePickerGridVariants()} role="grid"> <div className={datePickerGridVariants()}>
{days.map((day, index) => { {days.map((day, index) => {
const outside = day.getMonth() !== visibleMonth.getMonth(); const outside = day.getMonth() !== visibleMonth.getMonth();
const selected = sameDay(day, selectedDate); const selected = sameDay(day, selectedDate);
@@ -528,7 +534,6 @@ export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function
today: isToday today: isToday
})} })}
aria-label={formatValue(day, locale)} aria-label={formatValue(day, locale)}
aria-pressed={selected}
className={datePickerDayVariants()} className={datePickerDayVariants()}
disabled={dayDisabled} disabled={dayDisabled}
onClick={() => { onClick={() => {
@@ -539,7 +544,7 @@ export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function
ref={(node) => { ref={(node) => {
dayRefs.current[index] = node; dayRefs.current[index] = node;
}} }}
role="gridcell" style={selected ? undefined : { color: "var(--color-foreground)" }}
type="button" type="button"
> >
{day.getDate()} {day.getDate()}
@@ -37,12 +37,12 @@ export const datePickerGridVariants = cva("grid grid-cols-7 gap-1");
export const datePickerDayVariants = cva( export const datePickerDayVariants = cva(
[ [
"inline-flex h-9 items-center justify-center rounded-[var(--ui-control-radius)] text-sm font-medium outline-none", "inline-flex h-9 items-center justify-center rounded-[var(--ui-control-radius)] text-sm font-medium text-[var(--color-foreground)] outline-none",
"transition-[background-color,color,box-shadow,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]", "transition-[background-color,color,box-shadow,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ui-panel-bg)]", "focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ui-panel-bg)]",
"data-[outside=true]:text-[color-mix(in_oklch,var(--color-muted-foreground)_78%,transparent)]", "data-[outside=true]:text-[var(--color-foreground)]",
"data-[today=true]:shadow-[inset_0_0_0_1px_color-mix(in_oklch,var(--color-primary)_26%,transparent)]", "data-[today=true]:shadow-[inset_0_0_0_1px_color-mix(in_oklch,var(--color-primary)_26%,transparent)]",
"data-[disabled=true]:pointer-events-none opacity-35", "data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-35",
"data-[selected=true]:bg-[var(--color-primary)] data-[selected=true]:text-[var(--color-primary-foreground)] data-[selected=true]:shadow-[var(--ui-control-shadow)]", "data-[selected=true]:bg-[var(--color-primary)] data-[selected=true]:text-[var(--color-primary-foreground)] data-[selected=true]:shadow-[var(--ui-control-shadow)]",
"hover:bg-[var(--ui-control-bg)] hover:text-[var(--color-foreground)]", "hover:bg-[var(--ui-control-bg)] hover:text-[var(--color-foreground)]",
getMotionRecipeClassNames("ring") getMotionRecipeClassNames("ring")