diff --git a/jest.config.js b/jest.config.js index 4eb52a22c..e0e2166df 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,4 +7,7 @@ module.exports = { testMatch: [join(__dirname, "src/**/*.test.{js,ts,tsx}")], setupFilesAfterEnv: ["/jest.setup.js"], preset: "ts-jest", + moduleNameMapper: { + "\\.(css|less|sass|scss)$": "/src/__mocks__/styleMock.js", + }, }; diff --git a/src/__mocks__/styleMock.js b/src/__mocks__/styleMock.js new file mode 100644 index 000000000..f053ebf79 --- /dev/null +++ b/src/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/src/calendar/CalendarState.ts b/src/calendar/CalendarState.ts index f790e8745..7547fc78a 100644 --- a/src/calendar/CalendarState.ts +++ b/src/calendar/CalendarState.ts @@ -29,13 +29,13 @@ import { import { CalendarProps } from "./index.d"; import { useWeekStart } from "./useWeekStart"; import { announce } from "../utils/LiveAnnouncer"; -import { generateDaysInMonthArray, isInvalid, useWeekDays } from "./__utils"; +import { isInvalid, useWeekDays, generateDaysInMonthArray } from "./__utils"; -export interface IUseCalendarProps extends CalendarProps { +export interface CalendarStateInitialProps extends Partial { id?: string; } -export function useCalendarState(props: IUseCalendarProps = {}) { +export function useCalendarState(props: CalendarStateInitialProps = {}) { const { minValue: initialMinValue, maxValue: initialMaxValue, @@ -142,6 +142,7 @@ export function useCalendarState(props: IUseCalendarProps = {}) { currentMonth, setCurrentMonth, focusedDate, + focusCell, setFocusedDate, focusNextDay() { focusCell(addDays(focusedDate, 1)); diff --git a/src/calendar/__keys.ts b/src/calendar/__keys.ts index 9ffe76e65..1b58767fc 100644 --- a/src/calendar/__keys.ts +++ b/src/calendar/__keys.ts @@ -17,6 +17,7 @@ const CALENDAR_STATE_KEYS = [ "currentMonth", "setCurrentMonth", "focusedDate", + "focusCell", "setFocusedDate", "focusNextDay", "focusPreviousDay", diff --git a/src/calendar/__utils.ts b/src/calendar/__utils.ts index 1ce39bf91..967883cbe 100644 --- a/src/calendar/__utils.ts +++ b/src/calendar/__utils.ts @@ -2,10 +2,11 @@ * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) * for these utils inspiration */ -import { DateValue } from "./index.d"; +import { endOfDay, setDay } from "date-fns"; import { RangeValue } from "@react-types/shared"; import { useDateFormatter } from "@react-aria/i18n"; -import { endOfDay, setDay, startOfDay } from "date-fns"; + +import { DateValue } from "../calendar/index.d"; export function isInvalid( date: Date, @@ -40,7 +41,7 @@ export function generateDaysInMonthArray( const daysInWeek = [...new Array(7).keys()].reduce( (days: Date[], dayIndex) => { const day = weekIndex * 7 + dayIndex - monthStartsAt + 1; - const cellDate = new Date(year, month, day); + const cellDate = new Date(year, month, day, new Date().getHours()); return [...days, cellDate]; }, @@ -58,7 +59,7 @@ export function makeRange(start: Date, end: Date): RangeValue { [start, end] = [end, start]; } - return { start: startOfDay(start), end: endOfDay(end) }; + return { start: start, end: endOfDay(end) }; } export function convertRange(range: RangeValue): RangeValue { diff --git a/src/calendar/stories/Calendar.stories.tsx b/src/calendar/stories/Calendar.stories.tsx index 3628ffdf0..b2c8bdde5 100644 --- a/src/calendar/stories/Calendar.stories.tsx +++ b/src/calendar/stories/Calendar.stories.tsx @@ -3,129 +3,16 @@ import { Meta } from "@storybook/react"; import { addDays, addWeeks, subWeeks } from "date-fns"; import "./index.css"; -import { - Calendar, - DateValue, - CalendarCell, - CalendarGrid, - CalendarHeader, - CalendarButton, - IUseCalendarProps, - useCalendarState, - CalendarCellButton, - CalendarWeekTitle, -} from "../index"; +import { DateValue } from "../index.d"; +import { CalendarComponent } from "./CalendarComponent"; export default { title: "Component/Calendar", } as Meta; -const CalendarComp: React.FC = props => { - const state = useCalendarState(props); - - return ( - -
- - - - - - - - - - - - - - - - - - - - - -
- - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} - - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - -
- ); -}; - -export const Default = () => ; +export const Default = () => ; export const DefaultValue = () => ( - + ); export const ControlledValue = () => { const [value, setValue] = React.useState(addDays(new Date(), 1)); @@ -137,27 +24,27 @@ export const ControlledValue = () => { onChange={e => setValue(new Date(e.target.value))} value={(value as Date).toISOString().slice(0, 10)} /> - + ); }; export const MinMaxDate = () => ( - + ); export const MinMaxDefaultDate = () => ( - ); export const isDisabled = () => ( - + ); export const isReadOnly = () => ( - + ); export const autoFocus = () => ( // eslint-disable-next-line jsx-a11y/no-autofocus - + ); diff --git a/src/calendar/stories/CalendarComponent.tsx b/src/calendar/stories/CalendarComponent.tsx new file mode 100644 index 000000000..1b582196d --- /dev/null +++ b/src/calendar/stories/CalendarComponent.tsx @@ -0,0 +1,122 @@ +import React from "react"; + +import "./index.css"; +import { + Calendar as CalendarWrapper, + CalendarButton, + CalendarCell, + CalendarCellButton, + CalendarGrid, + CalendarHeader, + CalendarWeekTitle, + CalendarStateInitialProps, + useCalendarState, +} from "../index"; +import { CalendarStateReturn } from "../CalendarState"; + +export const CalendarComp: React.FC = state => { + return ( + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + {state.weekDays.map((day, dayIndex) => { + return ( + + {day.abbr} + + ); + })} + + + + {state.daysInMonth.map((week, weekIndex) => ( + + {week.map((day, dayIndex) => ( + + + + ))} + + ))} + + +
+ ); +}; + +export const CalendarComponent: React.FC = props => { + const state = useCalendarState(props); + + return ; +}; diff --git a/src/calendar/stories/RangeCalendar.stories.tsx b/src/calendar/stories/RangeCalendar.stories.tsx index 41e080957..217a6b815 100644 --- a/src/calendar/stories/RangeCalendar.stories.tsx +++ b/src/calendar/stories/RangeCalendar.stories.tsx @@ -136,6 +136,7 @@ export const DefaultValue = () => ( export const ControlledValue = () => { const [start, setStart] = React.useState(subDays(new Date(), 1)); const [end, setEnd] = React.useState(addDays(new Date(), 1)); + return (
; + +export type DatePickerHTMLProps = BoxHTMLProps; + +export type DatePickerProps = DatePickerOptions & DatePickerHTMLProps; + +const isTouch = Boolean( + "ontouchstart" in window || + window.navigator.maxTouchPoints > 0 || + window.navigator.msMaxTouchPoints > 0, +); + +export const useDatePicker = createHook( + { + name: "DatePicker", + compose: useBox, + keys: DATE_PICKER_KEYS, + + useProps( + options, + { + onKeyDown: htmlOnKeyDown, + onClick: htmlOnClick, + onMouseDown: htmlOnMouseDown, + ...htmlProps + }, + ) { + const { + visible, + validationState, + isDisabled, + isReadOnly, + isRequired, + show, + pickerId, + dialogId, + first, + } = options; + + const onClick = () => { + if (isTouch) show(); + }; + + // Open the popover on alt + arrow down + const onKeyDown = createOnKeyDown({ + onKey: htmlOnKeyDown, + preventDefault: true, + keyMap: event => { + const isAlt = event.altKey; + + return { + ArrowDown: () => { + isAlt && show(); + }, + }; + }, + }); + + const onMouseDown = (e: React.MouseEvent) => { + e.stopPropagation(); + first(); + }; + + return { + id: pickerId, + role: "combobox", + "aria-haspopup": "dialog", + "aria-expanded": visible, + "aria-owns": visible ? dialogId : undefined, + "aria-invalid": ariaAttr(validationState === "invalid"), + "aria-disabled": ariaAttr(isDisabled), + "aria-readonly": ariaAttr(isReadOnly), + "aria-required": ariaAttr(isRequired), + onKeyDown: callAllHandlers(htmlOnKeyDown, onKeyDown), + onClick: callAllHandlers(htmlOnClick, onClick), + onMouseDown: callAllHandlers(htmlOnMouseDown, onMouseDown), + ...htmlProps, + }; + }, + }, +); + +export const DatePicker = createComponent({ + as: "div", + memo: true, + useHook: useDatePicker, +}); diff --git a/src/datepicker/DatePickerContent.ts b/src/datepicker/DatePickerContent.ts new file mode 100644 index 000000000..0f1414d65 --- /dev/null +++ b/src/datepicker/DatePickerContent.ts @@ -0,0 +1,36 @@ +import { createComponent, createHook } from "reakit-system"; +import { PopoverHTMLProps, PopoverOptions, usePopover } from "reakit"; + +import { callAllHandlers } from "@chakra-ui/utils"; +import { DATE_PICKER_CONTENT_KEYS } from "./__keys"; +import { DatePickerStateReturn } from "./DatePickerState"; + +export type DatePickerContentOptions = PopoverOptions & + Pick; + +export type DatePickerContentHTMLProps = PopoverHTMLProps; + +export type DatePickerContentProps = DatePickerContentOptions & + DatePickerContentHTMLProps; + +export const useDatePickerContent = createHook< + DatePickerContentOptions, + DatePickerContentHTMLProps +>({ + name: "DatePickerContent", + compose: usePopover, + keys: DATE_PICKER_CONTENT_KEYS, + + useProps({ dialogId }, { onMouseDown: htmlOnMouseDown, ...htmlProps }) { + return { + id: dialogId, + ...htmlProps, + }; + }, +}); + +export const DatePickerContent = createComponent({ + as: "div", + memo: true, + useHook: useDatePickerContent, +}); diff --git a/src/datepicker/DatePickerState.ts b/src/datepicker/DatePickerState.ts new file mode 100644 index 000000000..8414f0536 --- /dev/null +++ b/src/datepicker/DatePickerState.ts @@ -0,0 +1,122 @@ +/** + * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) + * We improved the Calendar from Stately [useDatePickerState](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/datepicker/src/useDatePickerState.ts) + * to work with Reakit System + */ + +import * as React from "react"; +import { isValid } from "date-fns"; +import { useControllableState } from "@chakra-ui/hooks"; +import { usePopoverState, unstable_useId as useId } from "reakit"; + +import { setTime, isInvalid } from "./__utils"; +import { DateValue, useCalendarState } from "../calendar"; +import { useSegmentState } from "../segment-spinner/SegmentState"; +import { DatePickerStateInitialProps, ValidationState } from "./index.d"; + +export const useDatePickerState = (props: DatePickerStateInitialProps = {}) => { + const { + value: initialDate, + defaultValue: defaultValueProp, + onChange, + minValue: minValueProp, + maxValue: maxValueProp, + isDisabled, + isReadOnly, + isRequired, + autoFocus, + pickerId: pickerIdProp, + dialogId: dialogIdProp, + formatOptions, + placeholderDate: placeholderDateProp, + } = props; + + const { id: pickerId } = useId({ id: pickerIdProp, baseId: "picker" }); + const { id: dialogId } = useId({ id: dialogIdProp, baseId: "dialog" }); + + const defaultValue = + defaultValueProp && isValid(defaultValueProp) + ? new Date(defaultValueProp) + : new Date(); + + const [value, setValue] = useControllableState({ + value: initialDate, + defaultValue, + onChange, + shouldUpdate: (prev, next) => prev !== next, + }); + + const dateValue = value && isValid(value) ? new Date(value) : undefined; + const minValue = + minValueProp && isValid(minValueProp) ? new Date(minValueProp) : undefined; + const maxValue = + maxValueProp && isValid(maxValueProp) ? new Date(maxValueProp) : undefined; + const placeholderDate = + placeholderDateProp && isValid(placeholderDateProp) + ? new Date(placeholderDateProp) + : undefined; + + // Intercept setValue to make sure the Time section is not changed by date selection in Calendar + const selectDate = (newValue: DateValue) => { + if (dateValue) { + setTime(new Date(newValue), dateValue); + } + + setValue(newValue); + popover.hide(); + }; + + const popover = usePopoverState(props); + const segmentState = useSegmentState({ + value: dateValue, + defaultValue, + onChange: setValue, + formatOptions, + placeholderDate, + }); + const calendar = useCalendarState({ + value: dateValue, + defaultValue, + onChange: selectDate, + }); + + const validationState: ValidationState = + props.validationState || + (isInvalid(dateValue, props.minValue, props.maxValue) + ? "invalid" + : "valid"); + + React.useEffect(() => { + if (popover.visible) { + calendar.setFocused(true); + dateValue && calendar.focusCell(dateValue); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [popover.visible]); + + React.useEffect(() => { + if (autoFocus) { + segmentState.first(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoFocus, segmentState.first]); + + return { + pickerId, + dialogId, + dateValue, + setDateValue: setValue, + selectDate, + validationState, + minValue, + maxValue, + isDisabled, + isReadOnly, + isRequired, + ...popover, + ...segmentState, + calendar, + }; +}; + +export type DatePickerStateReturn = ReturnType; diff --git a/src/datepicker/DatePickerTrigger.ts b/src/datepicker/DatePickerTrigger.ts new file mode 100644 index 000000000..f4c7ea03a --- /dev/null +++ b/src/datepicker/DatePickerTrigger.ts @@ -0,0 +1,52 @@ +import { callAllHandlers } from "@chakra-ui/utils"; +import { createComponent, createHook } from "reakit-system"; +import { + usePopoverDisclosure, + PopoverDisclosureHTMLProps, + PopoverDisclosureOptions, +} from "reakit"; + +import { DATE_PICKER_TRIGGER_KEYS } from "./__keys"; +import { DatePickerStateReturn } from "./DatePickerState"; + +export type DatePickerTriggerOptions = PopoverDisclosureOptions & + Pick; + +export type DatePickerTriggerHTMLProps = PopoverDisclosureHTMLProps; + +export type DatePickerTriggerProps = DatePickerTriggerOptions & + DatePickerTriggerHTMLProps; + +export const useDatePickerTrigger = createHook< + DatePickerTriggerOptions, + DatePickerTriggerHTMLProps +>({ + name: "DatePickerTrigger", + compose: usePopoverDisclosure, + keys: DATE_PICKER_TRIGGER_KEYS, + + useOptions(options, htmlProps) { + return { + disabled: options.isDisabled || options.isReadOnly, + ...options, + }; + }, + + useProps(_, { onMouseDown: htmlOnMouseDown, ...htmlProps }) { + const onMouseDown = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + return { + tabIndex: -1, + onMouseDown: callAllHandlers(htmlOnMouseDown, onMouseDown), + ...htmlProps, + }; + }, +}); + +export const DatePickerTrigger = createComponent({ + as: "div", + memo: true, + useHook: useDatePickerTrigger, +}); diff --git a/src/datepicker/DateSegment.ts b/src/datepicker/DateSegment.ts new file mode 100644 index 000000000..b58b0901a --- /dev/null +++ b/src/datepicker/DateSegment.ts @@ -0,0 +1,30 @@ +import { DatePickerStateReturn } from "."; +import { createComponent, createHook } from "reakit-system"; + +import { + useSegment, + SegmentOptions, + SegmentHTMLProps, +} from "../segment-spinner/Segment"; +import { DATE_SEGMENT_KEYS } from "./__keys"; + +export type DateSegmentOptions = SegmentOptions & DatePickerStateReturn; + +export type DateSegmentHTMLProps = SegmentHTMLProps; + +export type DateSegmentProps = DateSegmentOptions & DateSegmentHTMLProps; + +export const useDateSegment = createHook< + DateSegmentOptions, + DateSegmentHTMLProps +>({ + name: "DateSegment", + compose: useSegment, + keys: DATE_SEGMENT_KEYS, +}); + +export const DateSegment = createComponent({ + as: "div", + memo: true, + useHook: useDateSegment, +}); diff --git a/src/datepicker/DateSegmentField.ts b/src/datepicker/DateSegmentField.ts new file mode 100644 index 000000000..7699aa0e1 --- /dev/null +++ b/src/datepicker/DateSegmentField.ts @@ -0,0 +1,30 @@ +import { DatePickerStateReturn } from "."; +import { createComponent, createHook } from "reakit-system"; + +import { + SegmentFieldHTMLProps, + useSegmentField, +} from "../segment-spinner/SegmentField"; +import { DATE_SEGMENT_FIELD_KEYS } from "./__keys"; + +export type DateSegmentFieldOptions = DatePickerStateReturn; + +export type DateSegmentFieldHTMLProps = SegmentFieldHTMLProps; + +export type DateSegmentFieldProps = DateSegmentFieldOptions & + DateSegmentFieldHTMLProps; + +export const useDateSegmentField = createHook< + DateSegmentFieldOptions, + DateSegmentFieldHTMLProps +>({ + name: "DateSegmentField", + compose: useSegmentField, + keys: DATE_SEGMENT_FIELD_KEYS, +}); + +export const DateSegmentField = createComponent({ + as: "div", + memo: true, + useHook: useDateSegmentField, +}); diff --git a/src/datepicker/__keys.ts b/src/datepicker/__keys.ts new file mode 100644 index 000000000..7db11d422 --- /dev/null +++ b/src/datepicker/__keys.ts @@ -0,0 +1,85 @@ +// Automatically generated +const DATE_PICKER_STATE_KEYS = [ + "calendar", + "value", + "setValue", + "segments", + "dateFormatter", + "increment", + "decrement", + "incrementPage", + "decrementPage", + "setSegment", + "confirmPlaceholder", + "baseId", + "unstable_idCountRef", + "setBaseId", + "unstable_virtual", + "rtl", + "orientation", + "items", + "groups", + "currentId", + "loop", + "wrap", + "unstable_moves", + "unstable_angular", + "unstable_hasActiveWidget", + "registerItem", + "unregisterItem", + "registerGroup", + "unregisterGroup", + "move", + "next", + "previous", + "up", + "down", + "first", + "last", + "sort", + "unstable_setVirtual", + "setRTL", + "setOrientation", + "setCurrentId", + "setLoop", + "setWrap", + "reset", + "unstable_setHasActiveWidget", + "visible", + "animated", + "animating", + "show", + "hide", + "toggle", + "setVisible", + "setAnimated", + "stopAnimation", + "modal", + "unstable_disclosureRef", + "setModal", + "unstable_referenceRef", + "unstable_popoverRef", + "unstable_arrowRef", + "unstable_popoverStyles", + "unstable_arrowStyles", + "unstable_originalPlacement", + "unstable_update", + "placement", + "place", + "pickerId", + "dialogId", + "dateValue", + "setDateValue", + "selectDate", + "validationState", + "minValue", + "maxValue", + "isDisabled", + "isReadOnly", + "isRequired", +] as const; +export const DATE_PICKER_KEYS = DATE_PICKER_STATE_KEYS; +export const DATE_PICKER_CONTENT_KEYS = DATE_PICKER_KEYS; +export const DATE_PICKER_TRIGGER_KEYS = DATE_PICKER_CONTENT_KEYS; +export const DATE_SEGMENT_KEYS = DATE_PICKER_TRIGGER_KEYS; +export const DATE_SEGMENT_FIELD_KEYS = DATE_SEGMENT_KEYS; diff --git a/src/datepicker/__utils.ts b/src/datepicker/__utils.ts new file mode 100644 index 000000000..3d8d8b001 --- /dev/null +++ b/src/datepicker/__utils.ts @@ -0,0 +1,24 @@ +import { DateValue } from "../calendar/index.d"; + +export function setTime(date: Date, time: Date) { + if (!date || !time) { + return; + } + + date.setHours(time.getHours()); + date.setMinutes(time.getMinutes()); + date.setSeconds(time.getSeconds()); + date.setMilliseconds(time.getMilliseconds()); +} + +export function isInvalid( + value: Date | undefined, + minValue?: DateValue, + maxValue?: DateValue, +) { + return ( + value != null && + ((minValue != null && value < new Date(minValue)) || + (maxValue != null && value > new Date(maxValue))) + ); +} diff --git a/src/datepicker/index.d.ts b/src/datepicker/index.d.ts new file mode 100644 index 000000000..00373e6fa --- /dev/null +++ b/src/datepicker/index.d.ts @@ -0,0 +1,23 @@ +import { PopoverInitialState } from "reakit"; +import { + InputBase, + Validation, + FocusableProps, + ValueBase, +} from "@react-types/shared"; + +interface DatePickerBase extends InputBase, Validation, FocusableProps { + minValue?: DateValue; + maxValue?: DateValue; + formatOptions?: Intl.DateTimeFormatOptions; + placeholderDate?: DateValue; + pickerId?: string; + dialogId?: string; +} + +export interface DatePickerStateInitialProps + extends DatePickerBase, + PopoverInitialState, + ValueBase {} + +export { ValidationState } from "@react-types/shared"; diff --git a/src/datepicker/index.ts b/src/datepicker/index.ts new file mode 100644 index 000000000..ced2f6eed --- /dev/null +++ b/src/datepicker/index.ts @@ -0,0 +1,6 @@ +export * from "./DatePicker"; +export * from "./DateSegment"; +export * from "./DatePickerState"; +export * from "./DateSegmentField"; +export * from "./DatePickerTrigger"; +export * from "./DatePickerContent"; diff --git a/src/datepicker/stories/DatePicker.stories.tsx b/src/datepicker/stories/DatePicker.stories.tsx new file mode 100644 index 000000000..6d4c1fb66 --- /dev/null +++ b/src/datepicker/stories/DatePicker.stories.tsx @@ -0,0 +1,106 @@ +import * as React from "react"; +import { Meta } from "@storybook/react"; +import { addDays, addWeeks, subWeeks } from "date-fns"; + +import "./index.css"; +import { DateValue } from "../../calendar/index.d"; +import { DatePickerStateInitialProps } from "../index.d"; +import { CalendarComp } from "../../calendar/stories/CalendarComponent"; +import { + DatePicker, + DateSegment, + DateSegmentField, + DatePickerContent, + DatePickerTrigger, + useDatePickerState, +} from "../index"; + +export default { + title: "Component/DatePicker", +} as Meta; + +const DatePickerComp: React.FC = props => { + const state = useDatePickerState({ + formatOptions: { month: "2-digit", day: "2-digit", year: "numeric" }, + ...props, + }); + + return ( + <> + +
+ + {state.segments.map((segment, i) => ( + + ))} + + + + + +
+
+ + + + + ); +}; + +const CalendarIcon = () => ( + +); + +export const Default = () => ; + +export const InitialDate = () => ( + +); + +export const ControllableState = () => { + const [value, setValue] = React.useState(addDays(new Date(), 1)); + + return ( +
+ setValue(new Date(e.target.value))} + value={new Date(value).toISOString().slice(0, 10)} + /> + +
+ ); +}; + +export const MinMaxDate = () => ( + +); + +export const InValidDate = () => ( + +); + +export const isDisabled = () => ( + +); + +export const isReadOnly = () => ( + +); + +export const autoFocus = () => ( + // eslint-disable-next-line jsx-a11y/no-autofocus + +); diff --git a/src/datepicker/stories/index.css b/src/datepicker/stories/index.css new file mode 100644 index 000000000..1c71892a4 --- /dev/null +++ b/src/datepicker/stories/index.css @@ -0,0 +1,50 @@ +.datepicker__trigger { + display: flex; + padding: 5px; + margin-left: 10px; + background-color: #f8f8f8; +} + +.datepicker__trigger svg { + fill: #43424d; + width: 20px; +} + +.datepicker__header { + padding: 0; + border-radius: 4px; + width: fit-content; + display: flex; + align-items: center; + padding-left: 10px; + border: 1px solid rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.datepicker__header:focus-within { + border: 1px solid #1e65fd; +} + +[aria-invalid="true"] > .datepicker__header { + border: 1px solid #c00000; +} + +.datepicker__field { + font-family: monospace; + display: flex; +} + +.datepicker__field--item { + padding: 2px; + border-radius: 4px; +} + +.datepicker__field--item:focus { + background-color: #1e65fd; + color: white; + outline: none; +} + +.datepicker [aria-disabled="true"] { + opacity: 0.5; +} diff --git a/src/segment-spinner/Segment.ts b/src/segment-spinner/Segment.ts new file mode 100644 index 000000000..537a48ddf --- /dev/null +++ b/src/segment-spinner/Segment.ts @@ -0,0 +1,252 @@ +import { + useCompositeItem, + CompositeItemOptions, + CompositeItemHTMLProps, + unstable_useId as useId, +} from "reakit"; +import { MouseEvent, useState } from "react"; +import { mergeProps } from "@react-aria/utils"; +import { callAllHandlers } from "@chakra-ui/utils"; +import { useDateFormatter } from "@react-aria/i18n"; +import { createComponent, createHook } from "reakit-system"; + +import { SEGMENT_KEYS } from "./__keys"; +import { isNumeric, parseNumber } from "./__utils"; +import { useSpinButton } from "../utils/useSpinButton"; +import { IDateSegment, SegmentStateReturn } from "./SegmentState"; + +export type SegmentOptions = CompositeItemOptions & + Pick< + SegmentStateReturn, + | "next" + | "fieldValue" + | "setSegment" + | "increment" + | "decrement" + | "incrementPage" + | "decrementPage" + | "dateFormatter" + | "confirmPlaceholder" + > & { + segment: IDateSegment; + isDisabled?: boolean; + isReadOnly?: boolean; + isRequired?: boolean; + }; + +export type SegmentHTMLProps = CompositeItemHTMLProps; + +export type SegmentProps = SegmentOptions & SegmentHTMLProps; + +export const useSegment = createHook({ + name: "Segment", + compose: useCompositeItem, + keys: SEGMENT_KEYS, + + useOptions(options, htmlProps) { + return { + disabled: + options.isDisabled || + options.isReadOnly || + options.segment.type === "literal", + ...options, + }; + }, + + useComposeProps(options, htmlProps) { + const composite = useCompositeItem(options, htmlProps); + + /* + Haz: + Ensure tabIndex={0} + Tab is not the only thing that can move focus in web pages + For example, on iOS you can move between form elements using + the arrows above the keyboard + */ + return { + ...composite, + tabIndex: options.disabled ? -1 : 0, + }; + }, + + useProps( + { segment, next, ...options }, + { + onMouseDown: htmlOnMouseDown, + onKeyDown: htmlOnKeyDown, + onFocus: htmlOnFocus, + ...htmlProps + }, + ) { + const [enteredKeys, setEnteredKeys] = useState(""); + + let textValue = segment.text; + const monthDateFormatter = useDateFormatter({ month: "long" }); + const hourDateFormatter = useDateFormatter({ + hour: "numeric", + hour12: options.dateFormatter.resolvedOptions().hour12, + }); + + if (segment.type === "month") { + textValue = monthDateFormatter.format(options.fieldValue); + } else if (segment.type === "hour" || segment.type === "dayPeriod") { + textValue = hourDateFormatter.format(options.fieldValue); + } + + const { spinButtonProps } = useSpinButton({ + value: segment.value, + textValue, + minValue: segment.minValue, + maxValue: segment.maxValue, + isDisabled: options.isDisabled, + isReadOnly: options.isReadOnly, + isRequired: options.isRequired, + onIncrement: () => options.increment(segment.type), + onDecrement: () => options.decrement(segment.type), + onIncrementPage: () => options.incrementPage(segment.type), + onDecrementPage: () => options.decrementPage(segment.type), + onIncrementToMax: () => + options.setSegment(segment.type, segment.maxValue as number), + onDecrementToMin: () => + options.setSegment(segment.type, segment.minValue as number), + }); + + const onKeyDown = (e: any) => { + if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) { + return; + } + + switch (e.key) { + case "Enter": + e.preventDefault(); + if (segment.isPlaceholder && !options.isReadOnly) { + options.confirmPlaceholder(segment.type); + } + next(); + break; + case "Tab": + break; + case "Backspace": { + e.preventDefault(); + if (isNumeric(segment.text) && !options.isReadOnly) { + const newValue = segment.text.slice(0, -1); + options.setSegment( + segment.type, + newValue.length === 0 + ? (segment.minValue as number) + : parseNumber(newValue), + ); + setEnteredKeys(newValue); + } + break; + } + default: + e.preventDefault(); + e.stopPropagation(); + if ( + (isNumeric(e.key) || /^[ap]$/.test(e.key)) && + !options.isReadOnly + ) { + onInput(e.key); + } + } + }; + + const onInput = (key: string) => { + const newValue = enteredKeys + key; + + switch (segment.type) { + case "dayPeriod": + if (key === "a") { + options.setSegment("dayPeriod", 0); + } else if (key === "p") { + options.setSegment("dayPeriod", 12); + } + next(); + break; + case "day": + case "hour": + case "minute": + case "second": + case "month": + case "year": { + if (!isNumeric(newValue)) { + return; + } + + const numberValue = parseNumber(newValue); + let segmentValue = numberValue; + if ( + segment.type === "hour" && + options.dateFormatter.resolvedOptions().hour12 && + numberValue === 12 + ) { + segmentValue = 0; + } else if (numberValue > (segment.maxValue as number)) { + segmentValue = parseNumber(key); + } + + options.setSegment(segment.type, segmentValue); + + if (Number(numberValue + "0") > (segment.maxValue as number)) { + setEnteredKeys(""); + next(); + } else { + setEnteredKeys(newValue); + } + break; + } + } + }; + + const onFocus = () => { + setEnteredKeys(""); + }; + + const onMouseDown = (e: MouseEvent) => e.stopPropagation(); + + const { id } = useId({ baseId: "segment-spin-button" }); + + switch (segment.type) { + // A separator, e.g. punctuation + case "literal": + return { + role: "presentation", + "data-placeholder": false, + children: segment.text, + ...htmlProps, + }; + + // These segments cannot be directly edited by the user. + case "weekday": + case "timeZoneName": + case "era": + return { + role: "presentation", + "data-placeholder": true, + children: segment.text, + ...htmlProps, + }; + + // Editable segment + default: + return mergeProps(spinButtonProps, { + id, + "aria-label": segment.type, + "aria-labelledby": `${options["aria-labelledby"]} ${id}`, + tabIndex: options.isDisabled ? undefined : 0, + onKeyDown: callAllHandlers(htmlOnKeyDown, onKeyDown), + onFocus: callAllHandlers(htmlOnFocus, onFocus), + onMouseDown: callAllHandlers(htmlOnMouseDown, onMouseDown), + children: segment.text, + ...htmlProps, + }); + } + }, +}); + +export const Segment = createComponent({ + as: "div", + memo: true, + useHook: useSegment, +}); diff --git a/src/segment-spinner/SegmentField.ts b/src/segment-spinner/SegmentField.ts new file mode 100644 index 000000000..e9d6034be --- /dev/null +++ b/src/segment-spinner/SegmentField.ts @@ -0,0 +1,29 @@ +import { createComponent, createHook } from "reakit-system"; +import { CompositeHTMLProps, CompositeOptions, useComposite } from "reakit"; + +import { SEGMENT_FIELD_KEYS } from "./__keys"; + +export type SegmentFieldOptions = CompositeOptions; + +export type SegmentFieldHTMLProps = CompositeHTMLProps; + +export type SegmentFieldProps = SegmentFieldOptions & SegmentFieldHTMLProps; + +export const useSegmentField = createHook< + SegmentFieldOptions, + SegmentFieldHTMLProps +>({ + name: "SegmentField", + compose: useComposite, + keys: SEGMENT_FIELD_KEYS, + + useProps(options, htmlProps) { + return htmlProps; + }, +}); + +export const SegmentField = createComponent({ + as: "div", + memo: true, + useHook: useSegmentField, +}); diff --git a/src/segment-spinner/SegmentState.ts b/src/segment-spinner/SegmentState.ts new file mode 100644 index 000000000..f4670dcb6 --- /dev/null +++ b/src/segment-spinner/SegmentState.ts @@ -0,0 +1,165 @@ +/** + * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) + * We improved the Calendar from Stately [useCalendarState](https://github.com/adobe/react-spectrum/tree/main/packages/%40react-stately/calendar) + * to work with Reakit System + */ + +import { useCompositeState } from "reakit"; +import { useMemo, useState } from "react"; +import { useDateFormatter } from "@react-aria/i18n"; +import { useControlledState } from "@react-stately/utils"; + +import { add, setSegment, convertValue, getSegmentLimits } from "./__utils"; + +export interface IDateSegment { + type: Intl.DateTimeFormatPartTypes; + text: string; + value?: number; + minValue?: number; + maxValue?: number; + isPlaceholder: boolean; +} + +const EDITABLE_SEGMENTS = { + year: true, + month: true, + day: true, + hour: true, + minute: true, + second: true, + dayPeriod: true, +}; + +const PAGE_STEP = { + year: 5, + month: 2, + day: 7, + hour: 2, + minute: 15, + second: 15, +}; + +// Node seems to convert everything to lowercase... +const TYPE_MAPPING = { + dayperiod: "dayPeriod", +}; + +export interface SegmentStateProps { + value?: Date; + defaultValue?: Date; + formatOptions?: Intl.DateTimeFormatOptions & { + timeStyle?: string; + dateStyle?: string; + }; + placeholderDate?: Date; + onChange?: (value: Date, ...args: any[]) => void; +} + +export function useSegmentState(props: SegmentStateProps) { + const segmentComposite = useCompositeState({ orientation: "horizontal" }); + const [validSegments, setValidSegments] = useState( + props.value || props.defaultValue ? { ...EDITABLE_SEGMENTS } : {}, + ); + + // https://stackoverflow.com/a/9893752/10629172 + const dateFormatter = useDateFormatter(props.formatOptions); + const resolvedOptions = useMemo(() => dateFormatter.resolvedOptions(), [ + dateFormatter, + ]); + + // Determine how many editable segments there are for validation purposes. + // The result is cached for performance. + const numSegments = useMemo( + () => + dateFormatter + .formatToParts(new Date()) + .filter(seg => EDITABLE_SEGMENTS[seg.type]).length, + [dateFormatter], + ); + + // If there is a value prop, and some segments were previously placeholders, mark them all as valid. + if (props.value && Object.keys(validSegments).length < numSegments) { + setValidSegments({ ...EDITABLE_SEGMENTS }); + } + + // We keep track of the placeholder date separately in state so that onChange is not called + // until all segments are set. If the value === null (not undefined), then assume the component + // is controlled, so use the placeholder as the value until all segments are entered so it doesn't + // change from uncontrolled to controlled and emit a warning. + const [placeholderDate, setPlaceholderDate] = useState( + convertValue(props.placeholderDate) || + new Date(new Date().getFullYear(), 0, 1), + ); + const [date, setDate] = useControlledState( + // @ts-ignore + props.value === null + ? convertValue(placeholderDate) + : convertValue(props.value), + convertValue(props.defaultValue), + props.onChange, + ); + + // If all segments are valid, use the date from state, otherwise use the placeholder date. + const value = + Object.keys(validSegments).length >= numSegments ? date : placeholderDate; + const setValue = (value: Date) => { + if (Object.keys(validSegments).length >= numSegments) { + setDate(value); + } else { + setPlaceholderDate(value); + } + }; + + const segments = dateFormatter.formatToParts(value).map( + segment => + ({ + type: TYPE_MAPPING[segment.type] || segment.type, + text: segment.value, + ...getSegmentLimits(value, segment.type, resolvedOptions), + isPlaceholder: !validSegments[segment.type], + } as IDateSegment), + ); + + const adjustSegment = ( + type: Intl.DateTimeFormatPartTypes, + amount: number, + ) => { + validSegments[type] = true; + setValidSegments({ ...validSegments }); + // @ts-ignore + setValue(add(value, type, amount, resolvedOptions)); + }; + + return { + ...segmentComposite, + fieldValue: value, + setFieldValue: setValue, + segments, + dateFormatter, + increment(part: Intl.DateTimeFormatPartTypes) { + adjustSegment(part, 1); + }, + decrement(part: Intl.DateTimeFormatPartTypes) { + adjustSegment(part, -1); + }, + incrementPage(part: Intl.DateTimeFormatPartTypes) { + adjustSegment(part, PAGE_STEP[part] || 1); + }, + decrementPage(part: Intl.DateTimeFormatPartTypes) { + adjustSegment(part, -(PAGE_STEP[part] || 1)); + }, + setSegment(part: Intl.DateTimeFormatPartTypes, v: number) { + validSegments[part] = true; + setValidSegments({ ...validSegments }); + // @ts-ignore + setValue(setSegment(value, part, v, resolvedOptions)); + }, + confirmPlaceholder(part: Intl.DateTimeFormatPartTypes) { + validSegments[part] = true; + setValidSegments({ ...validSegments }); + setValue(new Date(value)); + }, + }; +} + +export type SegmentStateReturn = ReturnType; diff --git a/src/segment-spinner/__keys.ts b/src/segment-spinner/__keys.ts new file mode 100644 index 000000000..5da53dcf4 --- /dev/null +++ b/src/segment-spinner/__keys.ts @@ -0,0 +1,55 @@ +// Automatically generated +const SEGMENT_STATE_KEYS = [ + "value", + "setValue", + "segments", + "dateFormatter", + "increment", + "decrement", + "incrementPage", + "decrementPage", + "setSegment", + "confirmPlaceholder", + "baseId", + "unstable_idCountRef", + "setBaseId", + "unstable_virtual", + "rtl", + "orientation", + "items", + "groups", + "currentId", + "loop", + "wrap", + "unstable_moves", + "unstable_angular", + "unstable_hasActiveWidget", + "registerItem", + "unregisterItem", + "registerGroup", + "unregisterGroup", + "move", + "next", + "previous", + "up", + "down", + "first", + "last", + "sort", + "unstable_setVirtual", + "setRTL", + "setOrientation", + "setCurrentId", + "setLoop", + "setWrap", + "reset", + "unstable_setHasActiveWidget", +] as const; +export const SEGMENT_KEYS = [ + ...SEGMENT_STATE_KEYS, + "segment", + "isDisabled", + "isReadOnly", + "isRequired", +] as const; +export const SEGMENT_FIELD_KEYS = SEGMENT_STATE_KEYS; diff --git a/src/segment-spinner/__utils.ts b/src/segment-spinner/__utils.ts new file mode 100644 index 000000000..d2ddc0736 --- /dev/null +++ b/src/segment-spinner/__utils.ts @@ -0,0 +1,232 @@ +import { + getDate, + getDaysInMonth, + getHours, + getMinutes, + getMonth, + getSeconds, + getYear, + setDate, + setHours, + setMinutes, + setMonth, + setSeconds, + setYear, +} from "date-fns"; +import { DateValue } from "../calendar/index.d"; + +export function convertValue(value: DateValue | undefined): Date | undefined { + if (!value) { + return undefined; + } + + return new Date(value); +} + +export function getSegmentLimits( + date: Date, + type: string, + options: Intl.ResolvedDateTimeFormatOptions, +) { + let value, minValue, maxValue; + switch (type) { + case "day": + value = getDate(date); + minValue = 1; + maxValue = getDaysInMonth(date); + break; + case "dayPeriod": + value = getHours(date) >= 12 ? 12 : 0; + minValue = 0; + maxValue = 12; + break; + case "hour": + value = getHours(date); + if (options.hour12) { + const isPM = value >= 12; + minValue = isPM ? 12 : 0; + maxValue = isPM ? 23 : 11; + } else { + minValue = 0; + maxValue = 23; + } + break; + case "minute": + value = getMinutes(date); + minValue = 0; + maxValue = 59; + break; + case "second": + value = getSeconds(date); + minValue = 0; + maxValue = 59; + break; + case "month": + value = getMonth(date) + 1; + minValue = 1; + maxValue = 12; + break; + case "year": + value = getYear(date); + minValue = 1; + maxValue = 9999; + break; + default: + return {}; + } + + return { + value, + minValue, + maxValue, + }; +} + +export function add( + value: Date, + part: string, + amount: number, + options: Intl.ResolvedDateTimeFormatOptions, +) { + switch (part) { + case "day": { + const day = getDate(value); + return setDate(value, cycleValue(day, amount, 1, getDaysInMonth(value))); + } + case "dayPeriod": { + const hours = getHours(value); + const isPM = hours >= 12; + return setHours(value, isPM ? hours - 12 : hours + 12); + } + case "hour": { + let hours = getHours(value); + let min = 0; + let max = 23; + if (options.hour12) { + const isPM = hours >= 12; + min = isPM ? 12 : 0; + max = isPM ? 23 : 11; + } + hours = cycleValue(hours, amount, min, max); + return setHours(value, hours); + } + case "minute": { + const minutes = cycleValue(getMinutes(value), amount, 0, 59, true); + return setMinutes(value, minutes); + } + case "month": { + const months = cycleValue(getMonth(value), amount, 0, 11); + return setMonth(value, months); + } + case "second": { + const seconds = cycleValue(getSeconds(value), amount, 0, 59, true); + return setSeconds(value, seconds); + } + case "year": { + const year = cycleValue(getYear(value), amount, 1, 9999, true); + return setYear(value, year); + } + } +} + +export function cycleValue( + value: number, + amount: number, + min: number, + max: number, + round = false, +) { + if (round) { + value += amount > 0 ? 1 : -1; + + if (value < min) { + value = max; + } + + const div = Math.abs(amount); + if (amount > 0) { + value = Math.ceil(value / div) * div; + } else { + value = Math.floor(value / div) * div; + } + + if (value > max) { + value = min; + } + } else { + value += amount; + if (value < min) { + value = max - (min - value - 1); + } else if (value > max) { + value = min + (value - max - 1); + } + } + + return value; +} + +export function setSegment( + value: Date, + part: string, + segmentValue: number, + options: Intl.ResolvedDateTimeFormatOptions, +) { + switch (part) { + case "day": + return setDate(value, segmentValue); + case "dayPeriod": { + const hours = getHours(value); + const wasPM = hours >= 12; + const isPM = segmentValue >= 12; + if (isPM === wasPM) { + return value; + } + return setHours(value, wasPM ? hours - 12 : hours + 12); + } + case "hour": + // In 12 hour time, ensure that AM/PM does not change + if (options.hour12) { + const hours = getHours(value); + const wasPM = hours >= 12; + if (!wasPM && segmentValue === 12) { + segmentValue = 0; + } + if (wasPM && segmentValue < 12) { + segmentValue += 12; + } + } + return setHours(value, segmentValue); + case "minute": + return setMinutes(value, segmentValue); + case "month": + return setMonth(value, segmentValue - 1); + case "second": + return setSeconds(value, segmentValue); + case "year": + return setYear(value, segmentValue); + } +} + +// Converts unicode number strings to real JS numbers. +// Numbers can be displayed and typed in many number systems, but JS +// only understands latin numbers. +// See https://www.fileformat.info/info/unicode/category/Nd/list.htm +// for a list of unicode numeric characters. +// Currently only Arabic and Latin numbers are supported, but more +// could be added here in the future. +// Keep this in sync with `isNumeric` below. +export function parseNumber(str: string): number { + str = str + // Arabic Indic + .replace(/[\u0660-\u0669]/g, c => String(c.charCodeAt(0) - 0x0660)) + // Extended Arabic Indic + .replace(/[\u06f0-\u06f9]/g, c => String(c.charCodeAt(0) - 0x06f0)); + + return Number(str); +} + +// Checks whether a unicode string could be converted to a number. +// Keep this in sync with `parseNumber` above. +export function isNumeric(str: string) { + return /^[0-9\u0660-\u0669\u06f0-\u06f9]+$/.test(str); +} diff --git a/src/segment-spinner/stories/SegmentSpinner.stories.tsx b/src/segment-spinner/stories/SegmentSpinner.stories.tsx new file mode 100644 index 000000000..54cb70cda --- /dev/null +++ b/src/segment-spinner/stories/SegmentSpinner.stories.tsx @@ -0,0 +1,70 @@ +import * as React from "react"; +import { Meta } from "@storybook/react"; + +import { Segment } from "../Segment"; +import { SegmentField } from "../SegmentField"; +import { useSegmentState, SegmentStateProps } from "../SegmentState"; +import "./index.css"; + +export default { + title: "Component/Segment", +} as Meta; + +const SegmentSpinnerComp: React.FC = props => { + const state = useSegmentState(props); + + return ( +
+ + {state.segments.map((segment, i) => ( + + ))} + +
+ ); +}; + +export const Default = () => ( +
+
+      year: "numeric", month: "2-digit", day: "2-digit", weekday: "long",
+    
+ + +
timeStyle: "long", dateStyle: "short"
+ + +
timeStyle: "short", dateStyle: "long"
+ + +
timeStyle: "full", dateStyle: "full"
+ +
+); diff --git a/src/segment-spinner/stories/index.css b/src/segment-spinner/stories/index.css new file mode 100644 index 000000000..b6d01e483 --- /dev/null +++ b/src/segment-spinner/stories/index.css @@ -0,0 +1,21 @@ +.segment__field { + display: flex; +} + +.segment__field--item { + padding: 2px; + border-radius: 4px; +} +.segment__field--item:focus { + background-color: #1e65fd; + color: white; + outline: none; +} + +.segment_demo pre { + font-size: 12px; + color: rgb(101, 100, 124); +} +.segment_demo pre ~ pre { + margin-top: 35px; +} diff --git a/src/utils/useSpinButton.ts b/src/utils/useSpinButton.ts new file mode 100644 index 000000000..98ce45eb1 --- /dev/null +++ b/src/utils/useSpinButton.ts @@ -0,0 +1,181 @@ +/* + * 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 { announce } from "./storybook/"; +import { AriaButtonProps } from "@react-types/button"; +import { HTMLAttributes, useCallback, useEffect, useRef } from "react"; +import { + InputBase, + RangeInputBase, + Validation, + ValueBase, +} from "@react-types/shared"; + +export interface SpinButtonProps + extends InputBase, + Validation, + ValueBase, + RangeInputBase { + textValue?: string; + onIncrement?: () => void; + onIncrementPage?: () => void; + onDecrement?: () => void; + onDecrementPage?: () => void; + onDecrementToMin?: () => void; + onIncrementToMax?: () => void; +} + +export interface SpinbuttonAria { + spinButtonProps: HTMLAttributes; + incrementButtonProps: AriaButtonProps; + decrementButtonProps: AriaButtonProps; +} + +export function useSpinButton(props: SpinButtonProps): SpinbuttonAria { + const _async = useRef(); + const { + value, + textValue, + minValue, + maxValue, + isDisabled, + isReadOnly, + isRequired, + onIncrement, + onIncrementPage, + onDecrement, + onDecrementPage, + onDecrementToMin, + onIncrementToMax, + } = props; + + const clearAsync = () => clearTimeout(_async.current); + + // eslint-disable-next-line arrow-body-style + useEffect(() => { + return () => clearAsync(); + }, []); + + const onKeyDown = (e: any) => { + if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || isReadOnly) { + return; + } + + switch (e.key) { + // @ts-expect-error + case "PageUp": + if (onIncrementPage) { + e.preventDefault(); + onIncrementPage(); + break; + } + // fallthrough! + case "ArrowUp": + case "Up": + if (onIncrement) { + e.preventDefault(); + onIncrement(); + } + break; + // @ts-expect-error + case "PageDown": + if (onDecrementPage) { + e.preventDefault(); + onDecrementPage(); + break; + } + // fallthrough + case "ArrowDown": + case "Down": + if (onDecrement) { + e.preventDefault(); + onDecrement(); + } + break; + case "Home": + if (minValue != null && onDecrementToMin) { + e.preventDefault(); + onDecrementToMin(); + } + break; + case "End": + if (maxValue != null && onIncrementToMax) { + e.preventDefault(); + onIncrementToMax(); + } + break; + } + }; + + const isFocused = useRef(false); + const onFocus = () => { + isFocused.current = true; + }; + + const onBlur = () => { + isFocused.current = false; + }; + + // useEffect(() => { + // if (isFocused.current) { + // announce(textValue || `${value}`); + // } + // }, [textValue, value]); + + const onIncrementPressStart = useCallback( + (initialStepDelay: number) => { + onIncrement?.(); + // Start spinning after initial delay + _async.current = window.setTimeout( + () => onIncrementPressStart(60), + initialStepDelay, + ); + }, + [onIncrement], + ); + + const onDecrementPressStart = useCallback( + (initialStepDelay: number) => { + onDecrement?.(); + // Start spinning after initial delay + _async.current = window.setTimeout( + () => onDecrementPressStart(60), + initialStepDelay, + ); + }, + [onDecrement], + ); + + return { + spinButtonProps: { + role: "spinbutton", + "aria-valuenow": typeof value === "number" ? value : undefined, + "aria-valuetext": textValue, + "aria-valuemin": minValue, + "aria-valuemax": maxValue, + "aria-disabled": isDisabled, + "aria-readonly": isReadOnly, + "aria-required": isRequired, + onKeyDown, + onFocus, + onBlur, + }, + incrementButtonProps: { + onPressStart: () => onIncrementPressStart(400), + onPressEnd: clearAsync, + }, + decrementButtonProps: { + onPressStart: () => onDecrementPressStart(400), + onPressEnd: clearAsync, + }, + }; +}