diff --git a/.changeset/sixty-weeks-help.md b/.changeset/sixty-weeks-help.md new file mode 100644 index 0000000000..8f887edb7c --- /dev/null +++ b/.changeset/sixty-weeks-help.md @@ -0,0 +1,6 @@ +--- +"@nextui-org/calendar": patch +"@nextui-org/date-picker": patch +--- + +Added an aria and test label for the picker toggle. diff --git a/packages/components/calendar/src/calendar-picker-item.tsx b/packages/components/calendar/src/calendar-picker-item.tsx index 0708e7b568..47916e786c 100644 --- a/packages/components/calendar/src/calendar-picker-item.tsx +++ b/packages/components/calendar/src/calendar-picker-item.tsx @@ -12,7 +12,7 @@ import {mergeProps} from "@react-aria/utils"; const CalendarPickerItem = forwardRef< HTMLButtonElement, HTMLNextUIProps<"button"> & AriaButtonProps ->(({children, autoFocus, isDisabled, onKeyDown, ...otherProps}, ref) => { +>(({children, autoFocus, isDisabled, onKeyDown, onKeyUp, ...otherProps}, ref) => { const domRef = useDOMRef(ref); const {buttonProps: ariaButtonProps, isPressed} = useAriaButton( @@ -20,6 +20,7 @@ const CalendarPickerItem = forwardRef< elementType: "button", isDisabled, onKeyDown, + onKeyUp, ...otherProps, } as AriaButtonProps, domRef, diff --git a/packages/components/calendar/src/calendar-picker.tsx b/packages/components/calendar/src/calendar-picker.tsx index 76e4cf07e5..7745dcfc24 100644 --- a/packages/components/calendar/src/calendar-picker.tsx +++ b/packages/components/calendar/src/calendar-picker.tsx @@ -27,6 +27,7 @@ export function CalendarPicker(props: CalendarPickerProps) { isHeaderExpanded, onPickerItemPressed, onPickerItemKeyDown, + onPickerItemKeyUp, } = useCalendarPicker(props); const EmptyItem = useCallback( @@ -89,6 +90,7 @@ export function CalendarPicker(props: CalendarPickerProps) { data-value={month.value} tabIndex={!isHeaderExpanded || state.focusedDate?.month !== month.value ? -1 : 0} onKeyDown={(e) => onPickerItemKeyDown(e, month.value, "months")} + onKeyUp={(e) => onPickerItemKeyUp(e, month.value, "months")} onPress={(e) => onPickerItemPressed(e, "months")} > {month.label} @@ -110,6 +112,7 @@ export function CalendarPicker(props: CalendarPickerProps) { data-value={year.value} tabIndex={!isHeaderExpanded || state.focusedDate?.year !== year.value ? -1 : 0} onKeyDown={(e) => onPickerItemKeyDown(e, year.value, "years")} + onKeyUp={(e) => onPickerItemKeyUp(e, year.value, "years")} onPress={(e) => onPickerItemPressed(e, "years")} > {year.label} diff --git a/packages/components/calendar/src/use-calendar-picker.ts b/packages/components/calendar/src/use-calendar-picker.ts index fca2c7bae1..d15b9e85b2 100644 --- a/packages/components/calendar/src/use-calendar-picker.ts +++ b/packages/components/calendar/src/use-calendar-picker.ts @@ -3,18 +3,19 @@ import type {PressEvent} from "@react-types/shared"; import {useDateFormatter} from "@react-aria/i18n"; import {HTMLNextUIProps} from "@nextui-org/system"; -import {useCallback, useRef, useEffect} from "react"; -import debounce from "lodash.debounce"; -import {areRectsIntersecting} from "@nextui-org/react-utils"; +import {useCallback, useEffect, useRef} from "react"; import scrollIntoView from "scroll-into-view-if-needed"; import {getMonthsInYear, getYearRange} from "./utils"; import {useCalendarContext} from "./calendar-context"; +import useScrollEndCallback from "./use-scroll-end-callback"; +import {useKeyRepeatBlocker} from "./use-key-repeat-blocker"; export type PickerValue = { value: string; label: string; }; + export interface CalendarPickerProps extends HTMLNextUIProps<"div"> { date: CalendarDate; currentMonth: CalendarDate; @@ -23,7 +24,30 @@ export interface CalendarPickerProps extends HTMLNextUIProps<"div"> { type ItemsRefMap = Map; type CalendarPickerListType = "months" | "years"; -const SCROLL_DEBOUNCE_TIME = 200; +const DEFAULT_BOUNDARY_VALUE = { + max: {months: 12, years: 2099}, + min: {months: 1, years: 1900}, +} as const; + +const LISTENED_NAVIGATION_KEYS = [ + "ArrowDown", + "ArrowUp", + "Home", + "End", + "PageUp", + "PageDown", + "Escape", + "Enter", + " ", +]; + +const OF_100_MILLISECONDS = 100; + +const HOME_AND_END_NEED_DEFERRED_FOCUS = ["Home", "End"]; + +function needsDeferredFocus(e: React.KeyboardEvent) { + return HOME_AND_END_NEED_DEFERRED_FOCUS.includes(e.key); +} export function useCalendarPicker(props: CalendarPickerProps) { const {date, currentMonth} = props; @@ -83,36 +107,6 @@ export function useCalendarPicker(props: CalendarPickerProps) { } } - const handleListScroll = useCallback( - (e: Event, highlightEl: HTMLElement | null, list: CalendarPickerListType) => { - if (!(e.target instanceof HTMLElement)) return; - - const map = getItemsRefMap(list === "months" ? monthsItemsRef : yearsItemsRef); - - const items = Array.from(map.values()); - - const item = items.find((itemEl) => { - const rect1 = itemEl.getBoundingClientRect(); - const rect2 = highlightEl?.getBoundingClientRect(); - - if (!rect2) { - return false; - } - - return areRectsIntersecting(rect1, rect2); - }); - - const itemValue = Number(item?.getAttribute("data-value")); - - if (!itemValue) return; - - let date = state.focusedDate.set(list === "months" ? {month: itemValue} : {year: itemValue}); - - state.setFocusedDate(date); - }, - [state, isHeaderExpanded], - ); - // scroll to the selected month/year when the component is mounted/opened/closed useEffect(() => { if (!isHeaderExpanded) return; @@ -121,36 +115,6 @@ export function useCalendarPicker(props: CalendarPickerProps) { scrollTo(date.year, "years", false); }, [isHeaderExpanded]); - useEffect(() => { - // add scroll event listener to monthsListRef - const monthsList = monthsListRef.current; - const yearsList = yearsListRef.current; - const highlightEl = highlightRef.current; - - if (!highlightEl) return; - - const debouncedHandleMonthsScroll = debounce( - (e: Event) => handleListScroll(e, highlightEl, "months"), - SCROLL_DEBOUNCE_TIME, - ); - const debouncedHandleYearsScroll = debounce( - (e: Event) => handleListScroll(e, highlightEl, "years"), - SCROLL_DEBOUNCE_TIME, - ); - - monthsList?.addEventListener("scroll", debouncedHandleMonthsScroll); - yearsList?.addEventListener("scroll", debouncedHandleYearsScroll); - - return () => { - if (debouncedHandleMonthsScroll) { - monthsList?.removeEventListener("scroll", debouncedHandleMonthsScroll); - } - if (debouncedHandleYearsScroll) { - yearsList?.removeEventListener("scroll", debouncedHandleYearsScroll); - } - }; - }, [handleListScroll]); - function scrollTo(value: number, list: CalendarPickerListType, smooth = true) { const mapListRef = list === "months" ? monthsItemsRef : yearsItemsRef; const listRef = list === "months" ? monthsListRef : yearsListRef; @@ -160,6 +124,9 @@ export function useCalendarPicker(props: CalendarPickerProps) { const node = map.get(value); if (!node) return; + let date = state.focusedDate.set(list === "months" ? {month: value} : {year: value}); + + state.setFocusedDate(date); // scroll picker list to the selected item scrollIntoView(node, { @@ -181,10 +148,32 @@ export function useCalendarPicker(props: CalendarPickerProps) { [state], ); + const {onScrollEnd, abortRef} = useScrollEndCallback(OF_100_MILLISECONDS); + const {handleKeyDown, handleKeyUp, isKeyDown} = useKeyRepeatBlocker( + HOME_AND_END_NEED_DEFERRED_FOCUS, + ); + + // Destructure before useCallback to ring-fence the dependency + const {maxValue, minValue} = state; + + const getBoundaryValue = useCallback( + (list: CalendarPickerListType, bound: "min" | "max") => { + let boundaryDate = bound === "min" ? minValue : maxValue; + const fromState = list === "months" ? boundaryDate?.month : boundaryDate?.year; + + return fromState ?? DEFAULT_BOUNDARY_VALUE[bound][list]; + }, + [minValue, maxValue], + ); + const onPickerItemKeyDown = useCallback( (e: React.KeyboardEvent, value: number, list: CalendarPickerListType) => { const map = getItemsRefMap(list === "months" ? monthsItemsRef : yearsItemsRef); + if (LISTENED_NAVIGATION_KEYS.includes(e.key)) { + e.preventDefault(); + } + const node = map.get(value); if (!node) return; @@ -199,10 +188,10 @@ export function useCalendarPicker(props: CalendarPickerProps) { nextValue = value - 1; break; case "Home": - nextValue = 0; + nextValue = getBoundaryValue(list, "min"); break; case "End": - nextValue = months.length - 1; + nextValue = getBoundaryValue(list, "max"); break; case "PageUp": nextValue = value - 3; @@ -221,7 +210,49 @@ export function useCalendarPicker(props: CalendarPickerProps) { const nextItem = map.get(nextValue); - nextItem?.focus(); + if (needsDeferredFocus(e)) { + if (!isKeyDown(e.key)) { + scrollTo(nextValue, list); + if (abortRef.current) { + abortRef.current(); + } + onScrollEnd(list === "months" ? monthsListRef.current : yearsListRef.current, () => { + nextItem?.focus(); + }); + handleKeyDown(e.key); + } + } else { + scrollTo(nextValue, list); + if (abortRef.current) { + abortRef.current(); + } + nextItem?.focus(); + } + }, + [state, handleKeyDown, isKeyDown], + ); + + const onPickerItemKeyUp = useCallback( + (e: React.KeyboardEvent, value: number, list: CalendarPickerListType) => { + const listRef = list === "months" ? monthsListRef : yearsListRef; + + if (LISTENED_NAVIGATION_KEYS.includes(e.key)) { + e.preventDefault(); + } + + // When the key up events fires we do a safety scroll to the element that fired it. + // Part of fixing issue #3789 + if (e.currentTarget) { + if (needsDeferredFocus(e)) { + handleKeyUp(e.key); + } else { + scrollIntoView(e.currentTarget, { + scrollMode: "always", + behavior: "smooth", + boundary: listRef.current, + }); + } + } }, [state], ); @@ -239,6 +270,7 @@ export function useCalendarPicker(props: CalendarPickerProps) { isHeaderExpanded, onPickerItemPressed, onPickerItemKeyDown, + onPickerItemKeyUp, }; } diff --git a/packages/components/calendar/src/use-key-repeat-blocker.ts b/packages/components/calendar/src/use-key-repeat-blocker.ts new file mode 100644 index 0000000000..dbc28ce96c --- /dev/null +++ b/packages/components/calendar/src/use-key-repeat-blocker.ts @@ -0,0 +1,51 @@ +import {useCallback, useRef} from "react"; + +// Type for the return API +interface UseKeyRepeatBlockerReturn { + handleKeyDown: (key: string) => void; + handleKeyUp: (key: string) => void; + isKeyDown: (key: string) => boolean; +} + +export const useKeyRepeatBlocker = (blockedKeys: string[]): UseKeyRepeatBlockerReturn => { + // Ref to store the mutable map of key pressed states + const keyPressedRef = useRef>({}); + + // useCallback to ensure stable function references + const handleKeyDown = useCallback( + (key: string) => { + if (blockedKeys.includes(key)) { + // If the key is already pressed, do nothing + if (keyPressedRef.current[key]) { + return; + } + + // Mark the key as pressed + keyPressedRef.current[key] = true; + } + }, + [blockedKeys], + ); + + const handleKeyUp = useCallback( + (key: string) => { + if (blockedKeys.includes(key)) { + // Mark the key as not pressed + keyPressedRef.current[key] = false; + } + }, + [blockedKeys], + ); + + // Function to check if a given key is already pressed + const isKeyDown = useCallback((key: string): boolean => { + return keyPressedRef.current[key]; + }, []); + + // Return the API + return { + handleKeyDown, + handleKeyUp, + isKeyDown, + }; +}; diff --git a/packages/components/calendar/src/use-scroll-end-callback.ts b/packages/components/calendar/src/use-scroll-end-callback.ts new file mode 100644 index 0000000000..3845310331 --- /dev/null +++ b/packages/components/calendar/src/use-scroll-end-callback.ts @@ -0,0 +1,69 @@ +import {useCallback, useEffect, useRef} from "react"; + +function useScrollEndCallback(debounce: number) { + const timeoutRef = useRef | null>(null); + const elementRef = useRef(null); + const abortRef = useRef<(() => void) | null>(); + const onScrollRef = useRef<(() => void) | null>(); + + const clearListener = useCallback(() => { + if (elementRef.current && onScrollRef.current) { + elementRef.current.removeEventListener("scroll", onScrollRef.current); + } + // Remove the event listener and clear timeout when the component unmounts + if (abortRef.current) { + abortRef.current(); + } + abortRef.current = null; + onScrollRef.current = null; + elementRef.current = null; + }, [elementRef]); + + // Cleanup on unmount + useEffect(() => { + return () => { + clearListener(); + }; + }, []); + + const onScrollEnd = useCallback( + (element: HTMLElement | null, callback: () => void) => { + if (!element) return; + clearListener(); + elementRef.current = element; + + // Clear the timeout if already set + const abort = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + + const onScroll = () => { + // Clear previous timeout to prevent firing too early + abort(); + + // Set new timeout to trigger the callback after scrolling stops + timeoutRef.current = setTimeout(() => { + callback(); + clearListener(); + }, debounce); // You can adjust the delay as necessary + }; + + onScrollRef.current = onScroll; + + // Add the scroll event listener to the element + element.addEventListener("scroll", onScroll); + + // onScroll(); + + abortRef.current = abort; + }, + [debounce], + ); + + return {onScrollEnd, abortRef}; +} + +export default useScrollEndCallback; diff --git a/packages/components/date-picker/__tests__/date-picker.test.tsx b/packages/components/date-picker/__tests__/date-picker.test.tsx index 8d95dd6346..9f3b26a559 100644 --- a/packages/components/date-picker/__tests__/date-picker.test.tsx +++ b/packages/components/date-picker/__tests__/date-picker.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable jsx-a11y/no-autofocus */ import * as React from "react"; -import {render, act, fireEvent, waitFor} from "@testing-library/react"; +import {act, fireEvent, render, waitFor} from "@testing-library/react"; import {pointerMap, triggerPress} from "@nextui-org/test-utils"; import userEvent from "@testing-library/user-event"; import {CalendarDate, CalendarDateTime} from "@internationalized/date"; @@ -524,6 +524,7 @@ describe("DatePicker", () => { describe("Month and Year Picker", () => { const onHeaderExpandedChangeSpy = jest.fn(); + const valueChangeSpy = jest.fn(); afterEach(() => { onHeaderExpandedChangeSpy.mockClear(); @@ -600,6 +601,48 @@ describe("DatePicker", () => { expect(onHeaderExpandedChangeSpy).not.toHaveBeenCalled(); }); + it("should focus the month and year when pressed in the picker", () => { + const {getByRole} = render( + , + ); + const dialogButton = getByRole("button"); + + triggerPress(dialogButton); + + const dialog = getByRole("dialog"); + const month = getByRole("button", {name: "April"}); + const year = getByRole("button", {name: "2024"}); + + expect(dialog).toBeVisible(); + expect(month).toHaveAttribute("data-value", "4"); + expect(year).toHaveAttribute("data-value", "2024"); + + const button = document.querySelector(`button[data-slot="header"]`)!; + + triggerPress(button); + + const monthBefore = getByRole("button", {name: "March"}); + const yearBefore = getByRole("button", {name: "2023"}); + + expect(monthBefore).toHaveAttribute("data-value", "3"); + expect(yearBefore).toHaveAttribute("data-value", "2023"); + expect(monthBefore).toHaveAttribute("tabindex", "-1"); + expect(yearBefore).toHaveAttribute("tabindex", "-1"); + triggerPress(monthBefore); + triggerPress(yearBefore); + expect(valueChangeSpy).not.toHaveBeenCalled(); + expect(monthBefore).toHaveAttribute("tabindex", "0"); + expect(yearBefore).toHaveAttribute("tabindex", "0"); + }); + it("CalendarBottomContent should render correctly", () => { const {getByRole, getByTestId} = render(