From ae8a61a86f7a32ca044373c604e0045352591eb3 Mon Sep 17 00:00:00 2001 From: Anurag Date: Wed, 23 Sep 2020 17:19:15 +0530 Subject: [PATCH 01/23] chore: wip datepicker --- src/calendar-v1/CalendarState.ts | 1 + src/calendar-v1/__keys.ts | 1 + src/datepicker/DatePicker.ts | 28 ++ src/datepicker/DatePickerContent.ts | 30 ++ src/datepicker/DatePickerSegment.ts | 69 ++++ src/datepicker/DatePickerSegmentInput.ts | 30 ++ src/datepicker/DatePickerState.ts | 63 +++ src/datepicker/DatePickerTrigger.ts | 34 ++ src/datepicker/__keys.ts | 86 ++++ src/datepicker/index.d.ts | 56 +++ src/datepicker/stories/Calendar.tsx | 113 ++++++ src/datepicker/stories/DatePicker.stories.tsx | 39 ++ src/datepicker/useDatePickerFieldState.ts | 379 ++++++++++++++++++ 13 files changed, 929 insertions(+) create mode 100644 src/datepicker/DatePicker.ts create mode 100644 src/datepicker/DatePickerContent.ts create mode 100644 src/datepicker/DatePickerSegment.ts create mode 100644 src/datepicker/DatePickerSegmentInput.ts create mode 100644 src/datepicker/DatePickerState.ts create mode 100644 src/datepicker/DatePickerTrigger.ts create mode 100644 src/datepicker/__keys.ts create mode 100644 src/datepicker/index.d.ts create mode 100644 src/datepicker/stories/Calendar.tsx create mode 100644 src/datepicker/stories/DatePicker.stories.tsx create mode 100644 src/datepicker/useDatePickerFieldState.ts diff --git a/src/calendar-v1/CalendarState.ts b/src/calendar-v1/CalendarState.ts index 988377ed6..70201790b 100644 --- a/src/calendar-v1/CalendarState.ts +++ b/src/calendar-v1/CalendarState.ts @@ -142,6 +142,7 @@ export function useCalendarState(props: IUseCalendarProps = {}) { currentMonth, setCurrentMonth, focusedDate, + focusCell, setFocusedDate, focusNextDay() { focusCell(addDays(focusedDate, 1)); diff --git a/src/calendar-v1/__keys.ts b/src/calendar-v1/__keys.ts index f7dee598e..204367cfd 100644 --- a/src/calendar-v1/__keys.ts +++ b/src/calendar-v1/__keys.ts @@ -17,6 +17,7 @@ const CALENDAR_STATE_KEYS = [ "currentMonth", "setCurrentMonth", "focusedDate", + "focusCell", "setFocusedDate", "focusNextDay", "focusPreviousDay", diff --git a/src/datepicker/DatePicker.ts b/src/datepicker/DatePicker.ts new file mode 100644 index 000000000..571942cef --- /dev/null +++ b/src/datepicker/DatePicker.ts @@ -0,0 +1,28 @@ +import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; +import { createComponent, createHook } from "reakit-system"; + +import { DATE_PICKER_KEYS } from "./__keys"; + +export type DatePickerOptions = BoxOptions; + +export type DatePickerHTMLProps = BoxHTMLProps; + +export type DatePickerProps = DatePickerOptions & DatePickerHTMLProps; + +export const useDatePicker = createHook( + { + name: "DatePicker", + compose: useBox, + keys: DATE_PICKER_KEYS, + + useProps(options, htmlProps) { + return 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..df9176d77 --- /dev/null +++ b/src/datepicker/DatePickerContent.ts @@ -0,0 +1,30 @@ +import { PopoverHTMLProps, PopoverOptions, usePopover } from "reakit"; +import { createComponent, createHook } from "reakit-system"; + +import { DATE_PICKER_CONTENT_KEYS } from "./__keys"; + +export type DatePickerContentOptions = PopoverOptions; + +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(options, htmlProps) { + return htmlProps; + }, +}); + +export const DatePickerContent = createComponent({ + as: "div", + memo: true, + useHook: useDatePickerContent, +}); diff --git a/src/datepicker/DatePickerSegment.ts b/src/datepicker/DatePickerSegment.ts new file mode 100644 index 000000000..566d7d7bd --- /dev/null +++ b/src/datepicker/DatePickerSegment.ts @@ -0,0 +1,69 @@ +import { callAllHandlers } from "@chakra-ui/utils"; +import { + CompositeItemHTMLProps, + CompositeItemOptions, + CompositeProps, + useCompositeItem, +} from "reakit"; +import { createComponent, createHook } from "reakit-system"; +import { + NumberInputHTMLProps, + NumberInputOptions, + NumberInputProps, + useNumberInput, +} from "../number-input"; +import { isValidNumericKeyboardEvent } from "../number-input/__utils"; +import { DatePickerStateReturn } from "./DatePickerState"; + +import { DATE_PICKER_SEGMENT_KEYS } from "./__keys"; + +export type DatePickerSegmentOptions = CompositeItemOptions & + NumberInputOptions & + Pick & { + type: "date" | "month" | "year"; + }; + +export type DatePickerSegmentHTMLProps = CompositeItemHTMLProps & + NumberInputHTMLProps; + +export type DatePickerSegmentProps = CompositeProps & + NumberInputProps & + DatePickerSegmentOptions & + DatePickerSegmentHTMLProps; + +export const useDatePickerSegment = createHook< + DatePickerSegmentOptions, + DatePickerSegmentHTMLProps +>({ + name: "DatePickerSegment", + compose: [useCompositeItem], + keys: DATE_PICKER_SEGMENT_KEYS, + + useProps(options, { onKeyDown: htmlOnKeyDown, ...htmlProps }) { + const compositeItem = useCompositeItem(options, htmlProps, true); + + const numberInputState = options.numberSegmentStates[options.type]; + const inputState = useNumberInput(numberInputState, htmlProps, true); + + const onKeyDown = (event: any) => { + event.preventDefault(); + if (isValidNumericKeyboardEvent(event)) { + numberInputState.update(+event.key); + } + }; + + return { + ...compositeItem, + ...inputState, + onKeyUp: onKeyDown, + children: inputState.value, + ...htmlProps, + }; + }, +}); + +export const DatePickerSegment = createComponent({ + as: "div", + memo: true, + useHook: useDatePickerSegment, +}); diff --git a/src/datepicker/DatePickerSegmentInput.ts b/src/datepicker/DatePickerSegmentInput.ts new file mode 100644 index 000000000..bf4c1ac50 --- /dev/null +++ b/src/datepicker/DatePickerSegmentInput.ts @@ -0,0 +1,30 @@ +import { CompositeHTMLProps, CompositeOptions, useComposite } from "reakit"; +import { createComponent, createHook } from "reakit-system"; + +import { DATE_PICKER_SEGMENT_INPUT_KEYS } from "./__keys"; + +export type DatePickerSegmentInputOptions = CompositeOptions; + +export type DatePickerSegmentInputHTMLProps = CompositeHTMLProps; + +export type DatePickerSegmentInputProps = DatePickerSegmentInputOptions & + DatePickerSegmentInputHTMLProps; + +export const useDatePickerSegmentInput = createHook< + DatePickerSegmentInputOptions, + DatePickerSegmentInputHTMLProps +>({ + name: "DatePickerSegmentInput", + compose: useComposite, + keys: DATE_PICKER_SEGMENT_INPUT_KEYS, + + useProps(options, htmlProps) { + return htmlProps; + }, +}); + +export const DatePickerSegmentInput = createComponent({ + as: "div", + memo: true, + useHook: useDatePickerSegmentInput, +}); diff --git a/src/datepicker/DatePickerState.ts b/src/datepicker/DatePickerState.ts new file mode 100644 index 000000000..b38e1e066 --- /dev/null +++ b/src/datepicker/DatePickerState.ts @@ -0,0 +1,63 @@ +import { parse, setDate, setMonth, setYear } from "date-fns"; +import React from "react"; +import { useCompositeState, useDisclosureState } from "reakit"; +import { useCalendarState } from "../calendar-v1"; +import { useNumberInputState } from "../number-input"; + +export const useDatePickerState = () => { + const segmentComposite = useCompositeState({ orientation: "horizontal" }); + const disclosure = useDisclosureState(); + + const calendar = useCalendarState({ + autoFocus: true, + defaultValue: new Date(), + onChange: date => { + disclosure.hide(); + }, + }); + + const currentDate = calendar.dateValue?.getDate(); + const currentMonth = calendar.currentMonth?.getMonth(); + const currentYear = calendar.dateValue?.getFullYear(); + + const numberSegmentStates = { + date: useNumberInputState({ + min: 1, + max: 31, + value: currentDate, + onChange: value => { + const date = setDate(calendar.dateValue as Date, parseInt(value)); + calendar.focusCell(date); + calendar.selectDate(date); + }, + }), + month: useNumberInputState({ + min: 1, + max: 12, + value: currentMonth, + onChange: value => { + const date = setMonth(calendar.dateValue as Date, parseInt(value)); + calendar.focusCell(date); + }, + }), + year: useNumberInputState({ + min: 1999, + max: 2999, + value: currentYear, + onChange: value => { + const date = setYear(calendar.dateValue as Date, parseInt(value)); + calendar.focusCell(date); + calendar.selectDate(date); + }, + }), + }; + + return { + ...segmentComposite, + ...disclosure, + ...calendar, + numberSegmentStates, + }; +}; + +export type DatePickerStateReturn = ReturnType; diff --git a/src/datepicker/DatePickerTrigger.ts b/src/datepicker/DatePickerTrigger.ts new file mode 100644 index 000000000..fe7d61fe7 --- /dev/null +++ b/src/datepicker/DatePickerTrigger.ts @@ -0,0 +1,34 @@ +import { + PopoverDisclosureHTMLProps, + PopoverDisclosureOptions, + usePopoverDisclosure, +} from "reakit"; +import { createComponent, createHook } from "reakit-system"; + +import { DATE_PICKER_TRIGGER_KEYS } from "./__keys"; + +export type DatePickerTriggerOptions = PopoverDisclosureOptions; + +export type DatePickerTriggerHTMLProps = PopoverDisclosureHTMLProps; + +export type DatePickerTriggerProps = DatePickerTriggerOptions & + DatePickerTriggerHTMLProps; + +export const useDatePickerTrigger = createHook< + DatePickerTriggerOptions, + DatePickerTriggerHTMLProps +>({ + name: "DatePickerTrigger", + compose: usePopoverDisclosure, + keys: DATE_PICKER_TRIGGER_KEYS, + + useProps(options, htmlProps) { + return htmlProps; + }, +}); + +export const DatePickerTrigger = createComponent({ + as: "div", + memo: true, + useHook: useDatePickerTrigger, +}); diff --git a/src/datepicker/__keys.ts b/src/datepicker/__keys.ts new file mode 100644 index 000000000..710b00fc6 --- /dev/null +++ b/src/datepicker/__keys.ts @@ -0,0 +1,86 @@ +// Automatically generated +const DATE_PICKER_STATE_KEYS = [ + "numberSegmentStates", + "calendarId", + "dateValue", + "minDate", + "maxDate", + "month", + "year", + "weekStart", + "weekDays", + "daysInMonth", + "isDisabled", + "isFocused", + "isReadOnly", + "setFocused", + "setDateValue", + "currentMonth", + "setCurrentMonth", + "focusedDate", + "focusCell", + "setFocusedDate", + "focusNextDay", + "focusPreviousDay", + "focusNextWeek", + "focusPreviousWeek", + "focusNextMonth", + "focusPreviousMonth", + "focusStartOfMonth", + "focusEndOfMonth", + "focusNextYear", + "focusPreviousYear", + "selectFocusedDate", + "selectDate", + "baseId", + "unstable_idCountRef", + "visible", + "animated", + "animating", + "setBaseId", + "show", + "hide", + "toggle", + "setVisible", + "setAnimated", + "stopAnimation", + "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 DATE_PICKER_KEYS = DATE_PICKER_STATE_KEYS; +export const DATE_PICKER_CONTENT_KEYS = DATE_PICKER_KEYS; +export const DATE_PICKER_SEGMENT_KEYS = [ + ...DATE_PICKER_CONTENT_KEYS, + "type", +] as const; +export const DATE_PICKER_SEGMENT_INPUT_KEYS = DATE_PICKER_CONTENT_KEYS; +export const DATE_PICKER_TRIGGER_KEYS = DATE_PICKER_SEGMENT_INPUT_KEYS; diff --git a/src/datepicker/index.d.ts b/src/datepicker/index.d.ts new file mode 100644 index 000000000..c950c5541 --- /dev/null +++ b/src/datepicker/index.d.ts @@ -0,0 +1,56 @@ +/* + * 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 { + DOMProps, + FocusableProps, + InputBase, + LabelableProps, + RangeValue, + SpectrumLabelableProps, + StyleProps, + Validation, + ValueBase, +} from "@react-types/shared"; + +export type DateValue = string | number | Date; +interface DatePickerBase + extends InputBase, + Validation, + FocusableProps, + LabelableProps { + minValue?: DateValue; + maxValue?: DateValue; + formatOptions?: Intl.DateTimeFormatOptions; + placeholderDate?: DateValue; +} + +export interface DatePickerProps extends DatePickerBase, ValueBase {} + +export type DateRange = RangeValue; +export interface DateRangePickerProps + extends DatePickerBase, + ValueBase {} + +interface SpectrumDatePickerBase + extends SpectrumLabelableProps, + DOMProps, + StyleProps { + isQuiet?: boolean; +} + +export interface SpectrumDatePickerProps + extends DatePickerProps, + SpectrumDatePickerBase {} +export interface SpectrumDateRangePickerProps + extends DateRangePickerProps, + SpectrumDatePickerBase {} diff --git a/src/datepicker/stories/Calendar.tsx b/src/datepicker/stories/Calendar.tsx new file mode 100644 index 000000000..c9c8b1996 --- /dev/null +++ b/src/datepicker/stories/Calendar.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import { + Calendar as CalendarWrapper, + CalendarButton, + CalendarCell, + CalendarCellButton, + CalendarGrid, + CalendarHeader, + CalendarStateReturn, + CalendarWeekTitle, +} from "../../calendar-v1"; +import "../../calendar-v1/stories/index.css"; + +export const Calendar: React.FC = state => { + return ( + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + {state.weekDays.map((day, dayIndex) => { + return ( + + {day.abbr} + + ); + })} + + + + {state.daysInMonth.map((week, weekIndex) => ( + + {week.map((day, dayIndex) => ( + + + + ))} + + ))} + + +
+ ); +}; diff --git a/src/datepicker/stories/DatePicker.stories.tsx b/src/datepicker/stories/DatePicker.stories.tsx new file mode 100644 index 000000000..7443b889f --- /dev/null +++ b/src/datepicker/stories/DatePicker.stories.tsx @@ -0,0 +1,39 @@ +import * as React from "react"; +import { Meta } from "@storybook/react"; +import { addDays, addWeeks, subWeeks } from "date-fns"; + +import { DatePicker } from "../DatePicker"; +import { DatePickerContent } from "../DatePickerContent"; +import { useDatePickerState } from "../DatePickerState"; +import { DatePickerTrigger } from "../DatePickerTrigger"; +import { Calendar } from "./Calendar"; +import { DatePickerSegmentInput } from "../DatePickerSegmentInput"; +import { DatePickerSegment } from "../DatePickerSegment"; + +export default { + title: "Component/DatePicker", +} as Meta; + +const DatePickerComp: React.FC = props => { + const state = useDatePickerState(); + + return ( + +
+ + + / + + / + + + [Open] +
+ {/* */} + + {/* */} +
+ ); +}; + +export const Default = () => ; diff --git a/src/datepicker/useDatePickerFieldState.ts b/src/datepicker/useDatePickerFieldState.ts new file mode 100644 index 000000000..7a6db561e --- /dev/null +++ b/src/datepicker/useDatePickerFieldState.ts @@ -0,0 +1,379 @@ +/* + * 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 { DatePickerProps, DateValue } from "./index.d"; +import { + getDate, + getDaysInMonth, + getHours, + getMinutes, + getMonth, + getSeconds, + getYear, + setDate, + setHours, + setMinutes, + setMonth, + setSeconds, + setYear, +} from "date-fns"; +import parse from "date-fns/parse"; +import { useControlledState } from "@react-stately/utils"; +import { useDateFormatter } from "@react-aria/i18n"; +import { useMemo, useState } from "react"; + +export interface DateSegment { + type: Intl.DateTimeFormatPartTypes; + text: string; + value?: number; + minValue?: number; + maxValue?: number; + isPlaceholder: boolean; +} + +export interface DatePickerFieldState { + value: Date; + setValue: (value: Date) => void; + segments: DateSegment[]; + dateFormatter: Intl.DateTimeFormat; + increment: (type: Intl.DateTimeFormatPartTypes) => void; + decrement: (type: Intl.DateTimeFormatPartTypes) => void; + incrementPage: (type: Intl.DateTimeFormatPartTypes) => void; + decrementPage: (type: Intl.DateTimeFormatPartTypes) => void; + setSegment: (type: Intl.DateTimeFormatPartTypes, value: number) => void; + confirmPlaceholder: (type: Intl.DateTimeFormatPartTypes) => void; +} + +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 function useDatePickerFieldState( + props: DatePickerProps, +): DatePickerFieldState { + const [validSegments, setValidSegments] = useState( + props.value || props.defaultValue ? { ...EDITABLE_SEGMENTS } : {}, + ); + + 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( + // @ts-ignore + convertValue(props.placeholderDate) || + new Date(new Date().getFullYear(), 0, 1), + ); + const [date, setDate] = useControlledState( + // @ts-ignore + props.value === null + ? convertValue(placeholderDate) + : // @ts-ignore + convertValue(props.value), + // @ts-ignore + 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 DateSegment), + ); + + const adjustSegment = ( + type: Intl.DateTimeFormatPartTypes, + amount: number, + ) => { + validSegments[type] = true; + setValidSegments({ ...validSegments }); + // @ts-ignore + setValue(add(value, type, amount, resolvedOptions)); + }; + + return { + value, + setValue, + segments, + dateFormatter, + increment(part) { + adjustSegment(part, 1); + }, + decrement(part) { + adjustSegment(part, -1); + }, + incrementPage(part) { + adjustSegment(part, PAGE_STEP[part] || 1); + }, + decrementPage(part) { + adjustSegment(part, -(PAGE_STEP[part] || 1)); + }, + setSegment(part, v) { + validSegments[part] = true; + setValidSegments({ ...validSegments }); + // @ts-ignore + setValue(setSegment(value, part, v, resolvedOptions)); + }, + confirmPlaceholder(part) { + validSegments[part] = true; + setValidSegments({ ...validSegments }); + setValue(new Date(value)); + }, + }; +} + +function convertValue(value: DateValue | null): Date | undefined { + if (!value) { + return undefined; + } + + return new Date(value); //parse(value); +} + +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, + }; +} + +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); + } + } +} + +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; +} + +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); + } +} From 571090cbed8591417bbdcc235b1bba70d3555573 Mon Sep 17 00:00:00 2001 From: Anurag Date: Wed, 23 Sep 2020 19:48:24 +0530 Subject: [PATCH 02/23] chore: wip style fixes --- src/datepicker/DatePickerSegment.ts | 2 +- src/datepicker/DatePickerState.ts | 4 ++-- src/datepicker/stories/DatePicker.stories.tsx | 18 +++++++++++++----- src/datepicker/stories/index.css | 8 ++++++++ 4 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 src/datepicker/stories/index.css diff --git a/src/datepicker/DatePickerSegment.ts b/src/datepicker/DatePickerSegment.ts index 566d7d7bd..1238bf1a6 100644 --- a/src/datepicker/DatePickerSegment.ts +++ b/src/datepicker/DatePickerSegment.ts @@ -48,7 +48,7 @@ export const useDatePickerSegment = createHook< const onKeyDown = (event: any) => { event.preventDefault(); if (isValidNumericKeyboardEvent(event)) { - numberInputState.update(+event.key); + // numberInputState.update(+event.key); } }; diff --git a/src/datepicker/DatePickerState.ts b/src/datepicker/DatePickerState.ts index b38e1e066..c8cf13fc0 100644 --- a/src/datepicker/DatePickerState.ts +++ b/src/datepicker/DatePickerState.ts @@ -17,7 +17,7 @@ export const useDatePickerState = () => { }); const currentDate = calendar.dateValue?.getDate(); - const currentMonth = calendar.currentMonth?.getMonth(); + const currentMonth = calendar.dateValue?.getMonth(); const currentYear = calendar.dateValue?.getFullYear(); const numberSegmentStates = { @@ -34,7 +34,7 @@ export const useDatePickerState = () => { month: useNumberInputState({ min: 1, max: 12, - value: currentMonth, + defaultValue: currentMonth, onChange: value => { const date = setMonth(calendar.dateValue as Date, parseInt(value)); calendar.focusCell(date); diff --git a/src/datepicker/stories/DatePicker.stories.tsx b/src/datepicker/stories/DatePicker.stories.tsx index 7443b889f..c408a7e9e 100644 --- a/src/datepicker/stories/DatePicker.stories.tsx +++ b/src/datepicker/stories/DatePicker.stories.tsx @@ -10,6 +10,8 @@ import { Calendar } from "./Calendar"; import { DatePickerSegmentInput } from "../DatePickerSegmentInput"; import { DatePickerSegment } from "../DatePickerSegment"; +import "./index.css"; + export default { title: "Component/DatePicker", } as Meta; @@ -19,7 +21,7 @@ const DatePickerComp: React.FC = props => { return ( -
+
/ @@ -27,11 +29,17 @@ const DatePickerComp: React.FC = props => { / - [Open] + + Open +
- {/* */} - - {/* */} + + + ); }; diff --git a/src/datepicker/stories/index.css b/src/datepicker/stories/index.css new file mode 100644 index 000000000..ad47bddb2 --- /dev/null +++ b/src/datepicker/stories/index.css @@ -0,0 +1,8 @@ +.datepicker__trigger { + margin-left: 5px; +} + +.datepicker__header { + display: flex; + align-items: center; +} From cc0b2014474246cedc68f9fac06d2b9574ddfa76 Mon Sep 17 00:00:00 2001 From: Anurag Date: Fri, 25 Sep 2020 17:36:49 +0530 Subject: [PATCH 03/23] feat: wip added datepicker --- src/datepicker/DatePickerContent.ts | 2 +- src/datepicker/DatePickerSegment.ts | 69 ----- src/datepicker/DatePickerSegmentInput.ts | 30 --- src/datepicker/DatePickerState.ts | 64 ++--- src/datepicker/DatePickerTrigger.ts | 6 +- src/datepicker/DateSegment.ts | 240 ++++++++++++++++++ src/datepicker/DateSegmentField.ts | 30 +++ src/datepicker/__keys.ts | 21 +- src/datepicker/stories/DatePicker.stories.tsx | 40 +-- src/datepicker/stories/index.css | 29 ++- src/datepicker/useDatePickerFieldState.ts | 9 +- src/utils/useSpinButton.ts | 179 +++++++++++++ 12 files changed, 547 insertions(+), 172 deletions(-) delete mode 100644 src/datepicker/DatePickerSegment.ts delete mode 100644 src/datepicker/DatePickerSegmentInput.ts create mode 100644 src/datepicker/DateSegment.ts create mode 100644 src/datepicker/DateSegmentField.ts create mode 100644 src/utils/useSpinButton.ts diff --git a/src/datepicker/DatePickerContent.ts b/src/datepicker/DatePickerContent.ts index df9176d77..770e219f6 100644 --- a/src/datepicker/DatePickerContent.ts +++ b/src/datepicker/DatePickerContent.ts @@ -1,5 +1,5 @@ -import { PopoverHTMLProps, PopoverOptions, usePopover } from "reakit"; import { createComponent, createHook } from "reakit-system"; +import { PopoverHTMLProps, PopoverOptions, usePopover } from "reakit"; import { DATE_PICKER_CONTENT_KEYS } from "./__keys"; diff --git a/src/datepicker/DatePickerSegment.ts b/src/datepicker/DatePickerSegment.ts deleted file mode 100644 index 1238bf1a6..000000000 --- a/src/datepicker/DatePickerSegment.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { callAllHandlers } from "@chakra-ui/utils"; -import { - CompositeItemHTMLProps, - CompositeItemOptions, - CompositeProps, - useCompositeItem, -} from "reakit"; -import { createComponent, createHook } from "reakit-system"; -import { - NumberInputHTMLProps, - NumberInputOptions, - NumberInputProps, - useNumberInput, -} from "../number-input"; -import { isValidNumericKeyboardEvent } from "../number-input/__utils"; -import { DatePickerStateReturn } from "./DatePickerState"; - -import { DATE_PICKER_SEGMENT_KEYS } from "./__keys"; - -export type DatePickerSegmentOptions = CompositeItemOptions & - NumberInputOptions & - Pick & { - type: "date" | "month" | "year"; - }; - -export type DatePickerSegmentHTMLProps = CompositeItemHTMLProps & - NumberInputHTMLProps; - -export type DatePickerSegmentProps = CompositeProps & - NumberInputProps & - DatePickerSegmentOptions & - DatePickerSegmentHTMLProps; - -export const useDatePickerSegment = createHook< - DatePickerSegmentOptions, - DatePickerSegmentHTMLProps ->({ - name: "DatePickerSegment", - compose: [useCompositeItem], - keys: DATE_PICKER_SEGMENT_KEYS, - - useProps(options, { onKeyDown: htmlOnKeyDown, ...htmlProps }) { - const compositeItem = useCompositeItem(options, htmlProps, true); - - const numberInputState = options.numberSegmentStates[options.type]; - const inputState = useNumberInput(numberInputState, htmlProps, true); - - const onKeyDown = (event: any) => { - event.preventDefault(); - if (isValidNumericKeyboardEvent(event)) { - // numberInputState.update(+event.key); - } - }; - - return { - ...compositeItem, - ...inputState, - onKeyUp: onKeyDown, - children: inputState.value, - ...htmlProps, - }; - }, -}); - -export const DatePickerSegment = createComponent({ - as: "div", - memo: true, - useHook: useDatePickerSegment, -}); diff --git a/src/datepicker/DatePickerSegmentInput.ts b/src/datepicker/DatePickerSegmentInput.ts deleted file mode 100644 index bf4c1ac50..000000000 --- a/src/datepicker/DatePickerSegmentInput.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { CompositeHTMLProps, CompositeOptions, useComposite } from "reakit"; -import { createComponent, createHook } from "reakit-system"; - -import { DATE_PICKER_SEGMENT_INPUT_KEYS } from "./__keys"; - -export type DatePickerSegmentInputOptions = CompositeOptions; - -export type DatePickerSegmentInputHTMLProps = CompositeHTMLProps; - -export type DatePickerSegmentInputProps = DatePickerSegmentInputOptions & - DatePickerSegmentInputHTMLProps; - -export const useDatePickerSegmentInput = createHook< - DatePickerSegmentInputOptions, - DatePickerSegmentInputHTMLProps ->({ - name: "DatePickerSegmentInput", - compose: useComposite, - keys: DATE_PICKER_SEGMENT_INPUT_KEYS, - - useProps(options, htmlProps) { - return htmlProps; - }, -}); - -export const DatePickerSegmentInput = createComponent({ - as: "div", - memo: true, - useHook: useDatePickerSegmentInput, -}); diff --git a/src/datepicker/DatePickerState.ts b/src/datepicker/DatePickerState.ts index c8cf13fc0..816331eb5 100644 --- a/src/datepicker/DatePickerState.ts +++ b/src/datepicker/DatePickerState.ts @@ -1,62 +1,46 @@ -import { parse, setDate, setMonth, setYear } from "date-fns"; import React from "react"; import { useCompositeState, useDisclosureState } from "reakit"; + import { useCalendarState } from "../calendar-v1"; -import { useNumberInputState } from "../number-input"; +import { useDatePickerFieldState } from "./useDatePickerFieldState"; + +interface DatePickerInitialProps { + initialDate?: Date; + dateFormat?: Intl.DateTimeFormatOptions; +} + +export const useDatePickerState = ({ + dateFormat, + initialDate = new Date(), +}: DatePickerInitialProps = {}) => { + const [date, setDate] = React.useState(initialDate); -export const useDatePickerState = () => { const segmentComposite = useCompositeState({ orientation: "horizontal" }); const disclosure = useDisclosureState(); const calendar = useCalendarState({ autoFocus: true, - defaultValue: new Date(), + value: date, onChange: date => { + setDate(new Date(date)); disclosure.hide(); }, }); - const currentDate = calendar.dateValue?.getDate(); - const currentMonth = calendar.dateValue?.getMonth(); - const currentYear = calendar.dateValue?.getFullYear(); - - const numberSegmentStates = { - date: useNumberInputState({ - min: 1, - max: 31, - value: currentDate, - onChange: value => { - const date = setDate(calendar.dateValue as Date, parseInt(value)); - calendar.focusCell(date); - calendar.selectDate(date); - }, - }), - month: useNumberInputState({ - min: 1, - max: 12, - defaultValue: currentMonth, - onChange: value => { - const date = setMonth(calendar.dateValue as Date, parseInt(value)); - calendar.focusCell(date); - }, - }), - year: useNumberInputState({ - min: 1999, - max: 2999, - value: currentYear, - onChange: value => { - const date = setYear(calendar.dateValue as Date, parseInt(value)); - calendar.focusCell(date); - calendar.selectDate(date); - }, - }), - }; + const fieldState = useDatePickerFieldState({ + formatOptions: dateFormat, + value: date, + onChange(v) { + setDate(new Date(v)); + calendar.focusCell(new Date(v)); + }, + }); return { + ...fieldState, ...segmentComposite, ...disclosure, ...calendar, - numberSegmentStates, }; }; diff --git a/src/datepicker/DatePickerTrigger.ts b/src/datepicker/DatePickerTrigger.ts index fe7d61fe7..6fc0729c2 100644 --- a/src/datepicker/DatePickerTrigger.ts +++ b/src/datepicker/DatePickerTrigger.ts @@ -3,11 +3,13 @@ import { PopoverDisclosureOptions, usePopoverDisclosure, } from "reakit"; -import { createComponent, createHook } from "reakit-system"; import { DATE_PICKER_TRIGGER_KEYS } from "./__keys"; +import { createComponent, createHook } from "reakit-system"; +import { DatePickerStateReturn } from "./DatePickerState"; -export type DatePickerTriggerOptions = PopoverDisclosureOptions; +export type DatePickerTriggerOptions = PopoverDisclosureOptions & + DatePickerStateReturn; export type DatePickerTriggerHTMLProps = PopoverDisclosureHTMLProps; diff --git a/src/datepicker/DateSegment.ts b/src/datepicker/DateSegment.ts new file mode 100644 index 000000000..5ba2bb4d8 --- /dev/null +++ b/src/datepicker/DateSegment.ts @@ -0,0 +1,240 @@ +import { MouseEvent, useState } from "react"; +import { DOMProps } from "@react-types/shared"; +import { useDateFormatter } from "@react-aria/i18n"; +import { mergeProps, useId } from "@react-aria/utils"; +import { createComponent, createHook } from "reakit-system"; +import { + BoxHTMLProps, + BoxOptions, + CompositeItemHTMLProps, + CompositeItemOptions, + useBox, + useCompositeItem, +} from "reakit"; + +import { + DatePickerFieldState, + DateSegment as IDateSegment, +} from "./useDatePickerFieldState"; +import { DatePickerProps } from "."; +import { USE_DATE_SEGMENT_KEYS } from "./__keys"; +import { useSpinButton } from "../utils/useSpinButton"; + +export type useDateSegmentOptions = CompositeItemOptions & + BoxOptions & + DatePickerFieldState & { segment: IDateSegment } & DatePickerProps; + +export type useDateSegmentHTMLProps = CompositeItemHTMLProps & + BoxHTMLProps & + DOMProps; + +export type useDateSegmentProps = useDateSegmentOptions & + useDateSegmentHTMLProps; + +export const useDateSegment = createHook< + useDateSegmentOptions, + useDateSegmentHTMLProps +>({ + name: "DateSegment", + compose: [useBox, useCompositeItem], + keys: USE_DATE_SEGMENT_KEYS, + + useOptions(options, htmlProps) { + return { + disabled: options.segment.type === "literal", + ...options, + }; + }, + + useProps({ segment, next, previous, ...state }, htmlProps) { + const [enteredKeys, setEnteredKeys] = useState(""); + + let textValue = segment.text; + const monthDateFormatter = useDateFormatter({ month: "long" }); + const hourDateFormatter = useDateFormatter({ + hour: "numeric", + hour12: state.dateFormatter.resolvedOptions().hour12, + }); + + if (segment.type === "month") { + textValue = monthDateFormatter.format(state.value); + } else if (segment.type === "hour" || segment.type === "dayPeriod") { + textValue = hourDateFormatter.format(state.value); + } + + const { spinButtonProps } = useSpinButton({ + value: segment.value, + textValue, + minValue: segment.minValue, + maxValue: segment.maxValue, + isDisabled: state.isDisabled, + isReadOnly: state.isReadOnly, + isRequired: state.isRequired, + onIncrement: () => state.increment(segment.type), + onDecrement: () => state.decrement(segment.type), + onIncrementPage: () => state.incrementPage(segment.type), + onDecrementPage: () => state.decrementPage(segment.type), + onIncrementToMax: () => + state.setSegment(segment.type, segment.maxValue as number), + onDecrementToMin: () => + state.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 && !state.isReadOnly) { + state.confirmPlaceholder(segment.type); + } + next(); + break; + case "Tab": + break; + case "Backspace": { + e.preventDefault(); + if (isNumeric(segment.text) && !state.isReadOnly) { + const newValue = segment.text.slice(0, -1); + state.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)) && !state.isReadOnly) { + onInput(e.key); + } + } + }; + + const onInput = (key: string) => { + const newValue = enteredKeys + key; + + switch (segment.type) { + case "dayPeriod": + if (key === "a") { + state.setSegment("dayPeriod", 0); + } else if (key === "p") { + state.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" && + state.dateFormatter.resolvedOptions().hour12 && + numberValue === 12 + ) { + segmentValue = 0; + } else if (numberValue > (segment.maxValue as number)) { + segmentValue = parseNumber(key); + } + + state.setSegment(segment.type, segmentValue); + + if (Number(numberValue + "0") > (segment.maxValue as number)) { + setEnteredKeys(""); + next(); + } else { + setEnteredKeys(newValue); + } + break; + } + } + }; + + const onFocus = () => { + setEnteredKeys(""); + }; + + const id = useId(htmlProps.id); + + 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": `${state["aria-labelledby"]} ${id}`, + tabIndex: state.isDisabled ? undefined : 0, + onKeyDown, + onFocus, + onMouseDown: (e: MouseEvent) => e.stopPropagation(), + children: segment.text, + ...htmlProps, + }); + } + }, +}); + +export const DateSegment = createComponent({ + as: "div", + memo: true, + useHook: useDateSegment, +}); + +// 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. +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. +function isNumeric(str: string) { + return /^[0-9\u0660-\u0669\u06f0-\u06f9]+$/.test(str); +} diff --git a/src/datepicker/DateSegmentField.ts b/src/datepicker/DateSegmentField.ts new file mode 100644 index 000000000..498206ea4 --- /dev/null +++ b/src/datepicker/DateSegmentField.ts @@ -0,0 +1,30 @@ +import { CompositeHTMLProps, CompositeOptions, useComposite } from "reakit"; +import { createComponent, createHook } from "reakit-system"; + +import { DATE_SEGMENT_FIELD_KEYS } from "./__keys"; + +export type DateSegmentFieldOptions = CompositeOptions; + +export type DateSegmentFieldHTMLProps = CompositeHTMLProps; + +export type DateSegmentFieldProps = DateSegmentFieldOptions & + DateSegmentFieldHTMLProps; + +export const useDateSegmentField = createHook< + DateSegmentFieldOptions, + DateSegmentFieldHTMLProps +>({ + name: "DateSegmentField", + compose: useComposite, + keys: DATE_SEGMENT_FIELD_KEYS, + + useProps(options, htmlProps) { + return htmlProps; + }, +}); + +export const DateSegmentField = createComponent({ + as: "div", + memo: true, + useHook: useDateSegmentField, +}); diff --git a/src/datepicker/__keys.ts b/src/datepicker/__keys.ts index 710b00fc6..a4a203fa4 100644 --- a/src/datepicker/__keys.ts +++ b/src/datepicker/__keys.ts @@ -1,6 +1,5 @@ // Automatically generated const DATE_PICKER_STATE_KEYS = [ - "numberSegmentStates", "calendarId", "dateValue", "minDate", @@ -75,12 +74,22 @@ const DATE_PICKER_STATE_KEYS = [ "setWrap", "reset", "unstable_setHasActiveWidget", + "value", + "setValue", + "segments", + "dateFormatter", + "increment", + "decrement", + "incrementPage", + "decrementPage", + "setSegment", + "confirmPlaceholder", ] as const; export const DATE_PICKER_KEYS = DATE_PICKER_STATE_KEYS; export const DATE_PICKER_CONTENT_KEYS = DATE_PICKER_KEYS; -export const DATE_PICKER_SEGMENT_KEYS = [ - ...DATE_PICKER_CONTENT_KEYS, - "type", +export const DATE_PICKER_TRIGGER_KEYS = DATE_PICKER_CONTENT_KEYS; +export const USE_DATE_SEGMENT_KEYS = [ + ...DATE_PICKER_TRIGGER_KEYS, + "segment", ] as const; -export const DATE_PICKER_SEGMENT_INPUT_KEYS = DATE_PICKER_CONTENT_KEYS; -export const DATE_PICKER_TRIGGER_KEYS = DATE_PICKER_SEGMENT_INPUT_KEYS; +export const DATE_SEGMENT_FIELD_KEYS = DATE_PICKER_TRIGGER_KEYS; diff --git a/src/datepicker/stories/DatePicker.stories.tsx b/src/datepicker/stories/DatePicker.stories.tsx index c408a7e9e..0fc281c8c 100644 --- a/src/datepicker/stories/DatePicker.stories.tsx +++ b/src/datepicker/stories/DatePicker.stories.tsx @@ -1,40 +1,39 @@ import * as React from "react"; import { Meta } from "@storybook/react"; -import { addDays, addWeeks, subWeeks } from "date-fns"; import { DatePicker } from "../DatePicker"; import { DatePickerContent } from "../DatePickerContent"; import { useDatePickerState } from "../DatePickerState"; import { DatePickerTrigger } from "../DatePickerTrigger"; import { Calendar } from "./Calendar"; -import { DatePickerSegmentInput } from "../DatePickerSegmentInput"; -import { DatePickerSegment } from "../DatePickerSegment"; import "./index.css"; +import { DateSegment } from "../DateSegment"; +import { DateSegmentField } from "../DateSegmentField"; export default { title: "Component/DatePicker", } as Meta; const DatePickerComp: React.FC = props => { - const state = useDatePickerState(); + const state = useDatePickerState({}); return (
- - - / - - / - - - - Open + + {state.segments.map((segment, i) => ( + + ))} + + + +
@@ -44,4 +43,11 @@ const DatePickerComp: React.FC = props => { ); }; +const CalendarIcon = () => ( + +); + export const Default = () => ; diff --git a/src/datepicker/stories/index.css b/src/datepicker/stories/index.css index ad47bddb2..2b74c5415 100644 --- a/src/datepicker/stories/index.css +++ b/src/datepicker/stories/index.css @@ -1,8 +1,35 @@ .datepicker__trigger { - margin-left: 5px; + 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); +} + +.datepicker__field { + display: flex; +} + +.datepicker__field--item { + padding: 2px; + border-radius: 4px; +} +.datepicker__field--item:focus { + background-color: #1e65fd; + color: white; + outline: none; } diff --git a/src/datepicker/useDatePickerFieldState.ts b/src/datepicker/useDatePickerFieldState.ts index 7a6db561e..59f13eaad 100644 --- a/src/datepicker/useDatePickerFieldState.ts +++ b/src/datepicker/useDatePickerFieldState.ts @@ -109,7 +109,6 @@ export function useDatePickerFieldState( // 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( - // @ts-ignore convertValue(props.placeholderDate) || new Date(new Date().getFullYear(), 0, 1), ); @@ -117,9 +116,7 @@ export function useDatePickerFieldState( // @ts-ignore props.value === null ? convertValue(placeholderDate) - : // @ts-ignore - convertValue(props.value), - // @ts-ignore + : convertValue(props.value), convertValue(props.defaultValue), props.onChange, ); @@ -186,12 +183,12 @@ export function useDatePickerFieldState( }; } -function convertValue(value: DateValue | null): Date | undefined { +function convertValue(value: DateValue | undefined): Date | undefined { if (!value) { return undefined; } - return new Date(value); //parse(value); + return new Date(value); } function getSegmentLimits( diff --git a/src/utils/useSpinButton.ts b/src/utils/useSpinButton.ts new file mode 100644 index 000000000..a48c62879 --- /dev/null +++ b/src/utils/useSpinButton.ts @@ -0,0 +1,179 @@ +/* + * 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) { + case "PageUp": + if (onIncrementPage) { + e.preventDefault(); + onIncrementPage(); + break; + } + // fallthrough! + case "ArrowUp": + case "Up": + if (onIncrement) { + e.preventDefault(); + onIncrement(); + } + break; + 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, + }, + }; +} From a1726248ddca42ece305f82169348ecf87e467ec Mon Sep 17 00:00:00 2001 From: Anurag Date: Fri, 25 Sep 2020 18:00:16 +0530 Subject: [PATCH 04/23] chore: focus on current date on datepicker toggle --- src/datepicker/DatePickerState.ts | 7 ++++++- src/datepicker/stories/DatePicker.stories.tsx | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/datepicker/DatePickerState.ts b/src/datepicker/DatePickerState.ts index 816331eb5..20c4e74dd 100644 --- a/src/datepicker/DatePickerState.ts +++ b/src/datepicker/DatePickerState.ts @@ -36,11 +36,16 @@ export const useDatePickerState = ({ }, }); + React.useEffect(() => { + calendar.setFocused(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [disclosure.visible]); + return { ...fieldState, ...segmentComposite, ...disclosure, - ...calendar, + calendar, }; }; diff --git a/src/datepicker/stories/DatePicker.stories.tsx b/src/datepicker/stories/DatePicker.stories.tsx index 0fc281c8c..6e03d2ff1 100644 --- a/src/datepicker/stories/DatePicker.stories.tsx +++ b/src/datepicker/stories/DatePicker.stories.tsx @@ -37,7 +37,7 @@ const DatePickerComp: React.FC = props => {
- +
); From d0db6870ee1856a962dde779f8fd42063e0439fb Mon Sep 17 00:00:00 2001 From: Anurag Date: Fri, 25 Sep 2020 18:20:15 +0530 Subject: [PATCH 05/23] chore: minor refactors --- ...rFieldState.ts => DatePickerFieldState.ts} | 7 ++- src/datepicker/DatePickerState.ts | 4 +- src/datepicker/DatePickerTrigger.ts | 11 +--- src/datepicker/DateSegment.ts | 2 +- src/datepicker/__keys.ts | 55 +++++-------------- src/datepicker/stories/DatePicker.stories.tsx | 13 ++--- 6 files changed, 31 insertions(+), 61 deletions(-) rename src/datepicker/{useDatePickerFieldState.ts => DatePickerFieldState.ts} (98%) diff --git a/src/datepicker/useDatePickerFieldState.ts b/src/datepicker/DatePickerFieldState.ts similarity index 98% rename from src/datepicker/useDatePickerFieldState.ts rename to src/datepicker/DatePickerFieldState.ts index 59f13eaad..59feb2185 100644 --- a/src/datepicker/useDatePickerFieldState.ts +++ b/src/datepicker/DatePickerFieldState.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import { DatePickerProps, DateValue } from "./index.d"; +import { DatePickerProps, DateValue } from "."; import { getDate, getDaysInMonth, @@ -26,7 +26,6 @@ import { setSeconds, setYear, } from "date-fns"; -import parse from "date-fns/parse"; import { useControlledState } from "@react-stately/utils"; import { useDateFormatter } from "@react-aria/i18n"; import { useMemo, useState } from "react"; @@ -183,6 +182,10 @@ export function useDatePickerFieldState( }; } +export type DatePickerFieldStateReturn = ReturnType< + typeof useDatePickerFieldState +>; + function convertValue(value: DateValue | undefined): Date | undefined { if (!value) { return undefined; diff --git a/src/datepicker/DatePickerState.ts b/src/datepicker/DatePickerState.ts index 20c4e74dd..3199e49ad 100644 --- a/src/datepicker/DatePickerState.ts +++ b/src/datepicker/DatePickerState.ts @@ -2,7 +2,7 @@ import React from "react"; import { useCompositeState, useDisclosureState } from "reakit"; import { useCalendarState } from "../calendar-v1"; -import { useDatePickerFieldState } from "./useDatePickerFieldState"; +import { useDatePickerFieldState } from "./DatePickerFieldState"; interface DatePickerInitialProps { initialDate?: Date; @@ -42,10 +42,10 @@ export const useDatePickerState = ({ }, [disclosure.visible]); return { + calendar, ...fieldState, ...segmentComposite, ...disclosure, - calendar, }; }; diff --git a/src/datepicker/DatePickerTrigger.ts b/src/datepicker/DatePickerTrigger.ts index 6fc0729c2..809a68555 100644 --- a/src/datepicker/DatePickerTrigger.ts +++ b/src/datepicker/DatePickerTrigger.ts @@ -1,15 +1,10 @@ -import { - PopoverDisclosureHTMLProps, - PopoverDisclosureOptions, - usePopoverDisclosure, -} from "reakit"; +import { usePopoverDisclosure, PopoverDisclosureHTMLProps } from "reakit"; import { DATE_PICKER_TRIGGER_KEYS } from "./__keys"; import { createComponent, createHook } from "reakit-system"; -import { DatePickerStateReturn } from "./DatePickerState"; -export type DatePickerTriggerOptions = PopoverDisclosureOptions & - DatePickerStateReturn; +// TODO: Fix Typescript error +export type DatePickerTriggerOptions = any; // PopoverDisclosureOptions; export type DatePickerTriggerHTMLProps = PopoverDisclosureHTMLProps; diff --git a/src/datepicker/DateSegment.ts b/src/datepicker/DateSegment.ts index 5ba2bb4d8..a4cb8ad27 100644 --- a/src/datepicker/DateSegment.ts +++ b/src/datepicker/DateSegment.ts @@ -15,7 +15,7 @@ import { import { DatePickerFieldState, DateSegment as IDateSegment, -} from "./useDatePickerFieldState"; +} from "./DatePickerFieldState"; import { DatePickerProps } from "."; import { USE_DATE_SEGMENT_KEYS } from "./__keys"; import { useSpinButton } from "../utils/useSpinButton"; diff --git a/src/datepicker/__keys.ts b/src/datepicker/__keys.ts index a4a203fa4..c2b68c3d8 100644 --- a/src/datepicker/__keys.ts +++ b/src/datepicker/__keys.ts @@ -1,36 +1,18 @@ // Automatically generated +const DATE_PICKER_FIELD_STATE_KEYS = [ + "value", + "setValue", + "segments", + "dateFormatter", + "increment", + "decrement", + "incrementPage", + "decrementPage", + "setSegment", + "confirmPlaceholder", +] as const; const DATE_PICKER_STATE_KEYS = [ - "calendarId", - "dateValue", - "minDate", - "maxDate", - "month", - "year", - "weekStart", - "weekDays", - "daysInMonth", - "isDisabled", - "isFocused", - "isReadOnly", - "setFocused", - "setDateValue", - "currentMonth", - "setCurrentMonth", - "focusedDate", - "focusCell", - "setFocusedDate", - "focusNextDay", - "focusPreviousDay", - "focusNextWeek", - "focusPreviousWeek", - "focusNextMonth", - "focusPreviousMonth", - "focusStartOfMonth", - "focusEndOfMonth", - "focusNextYear", - "focusPreviousYear", - "selectFocusedDate", - "selectDate", + ...DATE_PICKER_FIELD_STATE_KEYS, "baseId", "unstable_idCountRef", "visible", @@ -74,16 +56,7 @@ const DATE_PICKER_STATE_KEYS = [ "setWrap", "reset", "unstable_setHasActiveWidget", - "value", - "setValue", - "segments", - "dateFormatter", - "increment", - "decrement", - "incrementPage", - "decrementPage", - "setSegment", - "confirmPlaceholder", + "calendar", ] as const; export const DATE_PICKER_KEYS = DATE_PICKER_STATE_KEYS; export const DATE_PICKER_CONTENT_KEYS = DATE_PICKER_KEYS; diff --git a/src/datepicker/stories/DatePicker.stories.tsx b/src/datepicker/stories/DatePicker.stories.tsx index 6e03d2ff1..0c68be4c4 100644 --- a/src/datepicker/stories/DatePicker.stories.tsx +++ b/src/datepicker/stories/DatePicker.stories.tsx @@ -1,15 +1,14 @@ import * as React from "react"; import { Meta } from "@storybook/react"; -import { DatePicker } from "../DatePicker"; -import { DatePickerContent } from "../DatePickerContent"; -import { useDatePickerState } from "../DatePickerState"; -import { DatePickerTrigger } from "../DatePickerTrigger"; -import { Calendar } from "./Calendar"; - import "./index.css"; +import { Calendar } from "./Calendar"; +import { DatePicker } from "../DatePicker"; import { DateSegment } from "../DateSegment"; import { DateSegmentField } from "../DateSegmentField"; +import { DatePickerContent } from "../DatePickerContent"; +import { DatePickerTrigger } from "../DatePickerTrigger"; +import { useDatePickerState } from "../DatePickerState"; export default { title: "Component/DatePicker", @@ -24,9 +23,9 @@ const DatePickerComp: React.FC = props => { {state.segments.map((segment, i) => ( ))} From aebda13f0d293ef34295186bc76134aee3c4677b Mon Sep 17 00:00:00 2001 From: Anurag Date: Mon, 28 Sep 2020 11:09:22 +0530 Subject: [PATCH 06/23] fix: datepicker disclosure focus restore not working --- src/datepicker/DatePickerState.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/datepicker/DatePickerState.ts b/src/datepicker/DatePickerState.ts index 3199e49ad..b1fbf8c7f 100644 --- a/src/datepicker/DatePickerState.ts +++ b/src/datepicker/DatePickerState.ts @@ -19,7 +19,6 @@ export const useDatePickerState = ({ const disclosure = useDisclosureState(); const calendar = useCalendarState({ - autoFocus: true, value: date, onChange: date => { setDate(new Date(date)); @@ -37,7 +36,9 @@ export const useDatePickerState = ({ }); React.useEffect(() => { - calendar.setFocused(true); + if (disclosure.visible) { + calendar.setFocused(true); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [disclosure.visible]); From 46f4078ccb359a70014567b4afb66c564234aaaa Mon Sep 17 00:00:00 2001 From: Anurag Date: Mon, 28 Sep 2020 12:38:41 +0530 Subject: [PATCH 07/23] refactor: refactored duplicate calendar component --- src/calendar-v1/CalendarState.ts | 4 +- src/calendar-v1/stories/Calendar.stories.tsx | 117 +----------------- .../stories/CalendarComponent.tsx} | 6 +- src/datepicker/DatePickerState.ts | 14 +-- src/datepicker/stories/DatePicker.stories.tsx | 16 ++- 5 files changed, 28 insertions(+), 129 deletions(-) rename src/{datepicker/stories/Calendar.tsx => calendar-v1/stories/CalendarComponent.tsx} (95%) diff --git a/src/calendar-v1/CalendarState.ts b/src/calendar-v1/CalendarState.ts index 70201790b..7b32db216 100644 --- a/src/calendar-v1/CalendarState.ts +++ b/src/calendar-v1/CalendarState.ts @@ -27,7 +27,7 @@ import { useWeekStart } from "./useWeekStart"; import { isInvalid, useWeekDays } from "./__utils"; export type DateValue = string | number | Date; -export interface IUseCalendarProps { +export interface CalendarStateInitialProps { minValue?: DateValue; maxValue?: DateValue; isDisabled?: boolean; @@ -42,7 +42,7 @@ export interface IUseCalendarProps { id?: string; } -export function useCalendarState(props: IUseCalendarProps = {}) { +export function useCalendarState(props: CalendarStateInitialProps = {}) { const { minValue: initialMinValue, maxValue: initialMaxValue, diff --git a/src/calendar-v1/stories/Calendar.stories.tsx b/src/calendar-v1/stories/Calendar.stories.tsx index 289d211ab..ec02e1479 100644 --- a/src/calendar-v1/stories/Calendar.stories.tsx +++ b/src/calendar-v1/stories/Calendar.stories.tsx @@ -3,130 +3,23 @@ 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 { CalendarComponent } from "./CalendarComponent"; +import { CalendarStateInitialProps, useCalendarState, DateValue } from ".."; export default { title: "Component/Calendar", } as Meta; -const CalendarComp: React.FC = props => { +const CalendarComp: React.FC = props => { const state = useCalendarState(props); console.log("%c state", "color: #e5de73", state); - return ( - -
- - - - - - - - - - - - - - - - - - - - - -
- - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} - - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - -
- ); + return ; }; export const Default = () => ; export const DefaultValue = () => ( - + ); export const ControlledValue = () => { const [value, setValue] = React.useState(addDays(new Date(), 1)); diff --git a/src/datepicker/stories/Calendar.tsx b/src/calendar-v1/stories/CalendarComponent.tsx similarity index 95% rename from src/datepicker/stories/Calendar.tsx rename to src/calendar-v1/stories/CalendarComponent.tsx index c9c8b1996..ef3e43c8c 100644 --- a/src/datepicker/stories/Calendar.tsx +++ b/src/calendar-v1/stories/CalendarComponent.tsx @@ -8,10 +8,10 @@ import { CalendarHeader, CalendarStateReturn, CalendarWeekTitle, -} from "../../calendar-v1"; -import "../../calendar-v1/stories/index.css"; +} from ".."; +import "./index.css"; -export const Calendar: React.FC = state => { +export const CalendarComponent: React.FC = state => { return (
diff --git a/src/datepicker/DatePickerState.ts b/src/datepicker/DatePickerState.ts index b1fbf8c7f..a55a597bf 100644 --- a/src/datepicker/DatePickerState.ts +++ b/src/datepicker/DatePickerState.ts @@ -1,22 +1,22 @@ import React from "react"; import { useCompositeState, useDisclosureState } from "reakit"; -import { useCalendarState } from "../calendar-v1"; +import { useCalendarState, CalendarStateInitialProps } from "../calendar-v1"; import { useDatePickerFieldState } from "./DatePickerFieldState"; -interface DatePickerInitialProps { +export interface DatePickerStateInitialProps extends CalendarStateInitialProps { + visible?: boolean; initialDate?: Date; dateFormat?: Intl.DateTimeFormatOptions; } -export const useDatePickerState = ({ - dateFormat, - initialDate = new Date(), -}: DatePickerInitialProps = {}) => { +export const useDatePickerState = (props: DatePickerStateInitialProps = {}) => { + const { visible, dateFormat, initialDate = new Date() } = props; + const [date, setDate] = React.useState(initialDate); const segmentComposite = useCompositeState({ orientation: "horizontal" }); - const disclosure = useDisclosureState(); + const disclosure = useDisclosureState({ visible }); const calendar = useCalendarState({ value: date, diff --git a/src/datepicker/stories/DatePicker.stories.tsx b/src/datepicker/stories/DatePicker.stories.tsx index 0c68be4c4..20cafb9ee 100644 --- a/src/datepicker/stories/DatePicker.stories.tsx +++ b/src/datepicker/stories/DatePicker.stories.tsx @@ -2,20 +2,23 @@ import * as React from "react"; import { Meta } from "@storybook/react"; import "./index.css"; -import { Calendar } from "./Calendar"; +import { + useDatePickerState, + DatePickerStateInitialProps, +} from "../DatePickerState"; import { DatePicker } from "../DatePicker"; import { DateSegment } from "../DateSegment"; import { DateSegmentField } from "../DateSegmentField"; import { DatePickerContent } from "../DatePickerContent"; import { DatePickerTrigger } from "../DatePickerTrigger"; -import { useDatePickerState } from "../DatePickerState"; +import { CalendarComponent } from "../../calendar-v1/stories/CalendarComponent"; export default { title: "Component/DatePicker", } as Meta; -const DatePickerComp: React.FC = props => { - const state = useDatePickerState({}); +const DatePickerComp: React.FC = props => { + const state = useDatePickerState(props); return ( @@ -36,7 +39,7 @@ const DatePickerComp: React.FC = props => {
- + ); @@ -50,3 +53,6 @@ const CalendarIcon = () => ( ); export const Default = () => ; +export const InitialDate = () => ( + +); From 384186fd6f9b7f2c486036b7a4d7c15370d5f1d2 Mon Sep 17 00:00:00 2001 From: Anurag Date: Wed, 30 Sep 2020 12:04:01 +0530 Subject: [PATCH 08/23] feat: added Tab behaviour in datepicker spinbuttons composite --- src/datepicker/DatePickerTrigger.ts | 2 +- src/datepicker/DateSegmentField.ts | 22 ++++++++++++++++++- src/datepicker/stories/DatePicker.stories.tsx | 7 +++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/datepicker/DatePickerTrigger.ts b/src/datepicker/DatePickerTrigger.ts index 809a68555..74b321812 100644 --- a/src/datepicker/DatePickerTrigger.ts +++ b/src/datepicker/DatePickerTrigger.ts @@ -20,7 +20,7 @@ export const useDatePickerTrigger = createHook< keys: DATE_PICKER_TRIGGER_KEYS, useProps(options, htmlProps) { - return htmlProps; + return { tabIndex: -1, ...htmlProps }; }, }); diff --git a/src/datepicker/DateSegmentField.ts b/src/datepicker/DateSegmentField.ts index 498206ea4..eddb6147a 100644 --- a/src/datepicker/DateSegmentField.ts +++ b/src/datepicker/DateSegmentField.ts @@ -1,9 +1,12 @@ import { CompositeHTMLProps, CompositeOptions, useComposite } from "reakit"; import { createComponent, createHook } from "reakit-system"; +import { createOnKeyDown } from "reakit-utils"; +import { DatePickerStateReturn } from "./DatePickerState"; import { DATE_SEGMENT_FIELD_KEYS } from "./__keys"; -export type DateSegmentFieldOptions = CompositeOptions; +export type DateSegmentFieldOptions = CompositeOptions & + Pick; export type DateSegmentFieldHTMLProps = CompositeHTMLProps; @@ -18,6 +21,23 @@ export const useDateSegmentField = createHook< compose: useComposite, keys: DATE_SEGMENT_FIELD_KEYS, + useComposeProps(options, htmlProps) { + const composite = useComposite(options, htmlProps); + const onKeyDown = createOnKeyDown({ + onKey: composite.onKeyDown, + preventDefault: false, + keyMap: event => { + const isShift = event.shiftKey; + return { + Tab: () => { + isShift ? options.previous() : options.next(); + }, + }; + }, + }); + return { composite, onKeyDown, ...htmlProps }; + }, + useProps(options, htmlProps) { return htmlProps; }, diff --git a/src/datepicker/stories/DatePicker.stories.tsx b/src/datepicker/stories/DatePicker.stories.tsx index 20cafb9ee..3b309e2a6 100644 --- a/src/datepicker/stories/DatePicker.stories.tsx +++ b/src/datepicker/stories/DatePicker.stories.tsx @@ -52,7 +52,12 @@ const CalendarIcon = () => ( ); -export const Default = () => ; +export const Default = () => ( + <> + + + +); export const InitialDate = () => ( ); From 3fd3f5d290182126b4530ad88f5b2a94feeba1b0 Mon Sep 17 00:00:00 2001 From: Anurag Date: Wed, 30 Sep 2020 12:21:15 +0530 Subject: [PATCH 09/23] fix: ensure tabIndex 0 --- src/datepicker/DateSegment.ts | 17 +++++++++++++++++ src/datepicker/DateSegmentField.ts | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/datepicker/DateSegment.ts b/src/datepicker/DateSegment.ts index a4cb8ad27..5260bdf24 100644 --- a/src/datepicker/DateSegment.ts +++ b/src/datepicker/DateSegment.ts @@ -46,6 +46,23 @@ export const useDateSegment = createHook< }; }, + 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 { + ...htmlProps, + ...composite, + tabIndex: options.segment.type === "literal" ? -1 : 0, + }; + }, + useProps({ segment, next, previous, ...state }, htmlProps) { const [enteredKeys, setEnteredKeys] = useState(""); diff --git a/src/datepicker/DateSegmentField.ts b/src/datepicker/DateSegmentField.ts index eddb6147a..714e328fd 100644 --- a/src/datepicker/DateSegmentField.ts +++ b/src/datepicker/DateSegmentField.ts @@ -39,7 +39,7 @@ export const useDateSegmentField = createHook< }, useProps(options, htmlProps) { - return htmlProps; + return { ...htmlProps }; }, }); From d5182975c9ee51b810415838f968860e67d0ca8f Mon Sep 17 00:00:00 2001 From: Anurag Date: Wed, 30 Sep 2020 12:27:09 +0530 Subject: [PATCH 10/23] feat: alt arrowdown to open dropdown --- src/datepicker/DateSegmentField.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/datepicker/DateSegmentField.ts b/src/datepicker/DateSegmentField.ts index 714e328fd..e20128eb6 100644 --- a/src/datepicker/DateSegmentField.ts +++ b/src/datepicker/DateSegmentField.ts @@ -6,7 +6,7 @@ import { DatePickerStateReturn } from "./DatePickerState"; import { DATE_SEGMENT_FIELD_KEYS } from "./__keys"; export type DateSegmentFieldOptions = CompositeOptions & - Pick; + Pick; export type DateSegmentFieldHTMLProps = CompositeHTMLProps; @@ -28,10 +28,14 @@ export const useDateSegmentField = createHook< preventDefault: false, keyMap: event => { const isShift = event.shiftKey; + const isAlt = event.altKey; return { Tab: () => { isShift ? options.previous() : options.next(); }, + ArrowDown: () => { + isAlt && options.show(); + }, }; }, }); From bde5dd8b0c56c0b35880376f9da014fee4161ef8 Mon Sep 17 00:00:00 2001 From: Anurag Date: Wed, 30 Sep 2020 12:48:23 +0530 Subject: [PATCH 11/23] refactor: reorganized & refactored code --- src/datepicker/DatePickerFieldState.ts | 221 +---------------- src/datepicker/DateSegment.ts | 73 ++---- src/datepicker/__keys.ts | 5 +- src/datepicker/__utils.ts | 232 ++++++++++++++++++ src/datepicker/index.ts | 7 + src/datepicker/stories/DatePicker.stories.tsx | 19 +- 6 files changed, 284 insertions(+), 273 deletions(-) create mode 100644 src/datepicker/__utils.ts create mode 100644 src/datepicker/index.ts diff --git a/src/datepicker/DatePickerFieldState.ts b/src/datepicker/DatePickerFieldState.ts index 59feb2185..c982e3e0a 100644 --- a/src/datepicker/DatePickerFieldState.ts +++ b/src/datepicker/DatePickerFieldState.ts @@ -10,27 +10,14 @@ * governing permissions and limitations under the License. */ -import { DatePickerProps, DateValue } from "."; -import { - getDate, - getDaysInMonth, - getHours, - getMinutes, - getMonth, - getSeconds, - getYear, - setDate, - setHours, - setMinutes, - setMonth, - setSeconds, - setYear, -} from "date-fns"; -import { useControlledState } from "@react-stately/utils"; -import { useDateFormatter } from "@react-aria/i18n"; import { useMemo, useState } from "react"; +import { useDateFormatter } from "@react-aria/i18n"; +import { useControlledState } from "@react-stately/utils"; + +import { DatePickerProps } from "./index.d"; +import { add, setSegment, convertValue, getSegmentLimits } from "./__utils"; -export interface DateSegment { +export interface IDateSegment { type: Intl.DateTimeFormatPartTypes; text: string; value?: number; @@ -42,7 +29,7 @@ export interface DateSegment { export interface DatePickerFieldState { value: Date; setValue: (value: Date) => void; - segments: DateSegment[]; + segments: IDateSegment[]; dateFormatter: Intl.DateTimeFormat; increment: (type: Intl.DateTimeFormatPartTypes) => void; decrement: (type: Intl.DateTimeFormatPartTypes) => void; @@ -138,7 +125,7 @@ export function useDatePickerFieldState( text: segment.value, ...getSegmentLimits(value, segment.type, resolvedOptions), isPlaceholder: !validSegments[segment.type], - } as DateSegment), + } as IDateSegment), ); const adjustSegment = ( @@ -185,195 +172,3 @@ export function useDatePickerFieldState( export type DatePickerFieldStateReturn = ReturnType< typeof useDatePickerFieldState >; - -function convertValue(value: DateValue | undefined): Date | undefined { - if (!value) { - return undefined; - } - - return new Date(value); -} - -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, - }; -} - -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); - } - } -} - -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; -} - -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); - } -} diff --git a/src/datepicker/DateSegment.ts b/src/datepicker/DateSegment.ts index 5260bdf24..ab60edcf4 100644 --- a/src/datepicker/DateSegment.ts +++ b/src/datepicker/DateSegment.ts @@ -1,43 +1,46 @@ +import { + useBox, + BoxOptions, + BoxHTMLProps, + useCompositeItem, + CompositeItemOptions, + CompositeItemHTMLProps, +} from "reakit"; import { MouseEvent, useState } from "react"; import { DOMProps } from "@react-types/shared"; import { useDateFormatter } from "@react-aria/i18n"; import { mergeProps, useId } from "@react-aria/utils"; import { createComponent, createHook } from "reakit-system"; -import { - BoxHTMLProps, - BoxOptions, - CompositeItemHTMLProps, - CompositeItemOptions, - useBox, - useCompositeItem, -} from "reakit"; -import { - DatePickerFieldState, - DateSegment as IDateSegment, -} from "./DatePickerFieldState"; -import { DatePickerProps } from "."; -import { USE_DATE_SEGMENT_KEYS } from "./__keys"; +import { DatePickerProps } from "./index.d"; +import { DATE_SEGMENT_KEYS } from "./__keys"; +import { isNumeric, parseNumber } from "./__utils"; import { useSpinButton } from "../utils/useSpinButton"; +import { DatePickerFieldState, IDateSegment } from "./DatePickerFieldState"; -export type useDateSegmentOptions = CompositeItemOptions & +export type DateSegmentOptions = CompositeItemOptions & BoxOptions & - DatePickerFieldState & { segment: IDateSegment } & DatePickerProps; - -export type useDateSegmentHTMLProps = CompositeItemHTMLProps & + DatePickerFieldState & + DatePickerProps & { + segment: IDateSegment; + isDisabled?: boolean; + isReadOnly?: boolean; + isRequired?: boolean; + }; + +export type DateSegmentHTMLProps = CompositeItemHTMLProps & BoxHTMLProps & DOMProps; -export type useDateSegmentProps = useDateSegmentOptions & - useDateSegmentHTMLProps; +export type DateSegmentProps = DateSegmentOptions & DateSegmentHTMLProps; export const useDateSegment = createHook< - useDateSegmentOptions, - useDateSegmentHTMLProps + DateSegmentOptions, + DateSegmentHTMLProps >({ name: "DateSegment", compose: [useBox, useCompositeItem], - keys: USE_DATE_SEGMENT_KEYS, + keys: DATE_SEGMENT_KEYS, useOptions(options, htmlProps) { return { @@ -231,27 +234,3 @@ export const DateSegment = createComponent({ memo: true, useHook: useDateSegment, }); - -// 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. -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. -function isNumeric(str: string) { - return /^[0-9\u0660-\u0669\u06f0-\u06f9]+$/.test(str); -} diff --git a/src/datepicker/__keys.ts b/src/datepicker/__keys.ts index c2b68c3d8..0e3c90ea6 100644 --- a/src/datepicker/__keys.ts +++ b/src/datepicker/__keys.ts @@ -61,8 +61,11 @@ const DATE_PICKER_STATE_KEYS = [ 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 USE_DATE_SEGMENT_KEYS = [ +export const DATE_SEGMENT_KEYS = [ ...DATE_PICKER_TRIGGER_KEYS, "segment", + "isDisabled", + "isReadOnly", + "isRequired", ] as const; export const DATE_SEGMENT_FIELD_KEYS = DATE_PICKER_TRIGGER_KEYS; diff --git a/src/datepicker/__utils.ts b/src/datepicker/__utils.ts new file mode 100644 index 000000000..86d3dc3e6 --- /dev/null +++ b/src/datepicker/__utils.ts @@ -0,0 +1,232 @@ +import { DateValue } from "./index.d"; +import { + getDate, + getDaysInMonth, + getHours, + getMinutes, + getMonth, + getSeconds, + getYear, + setDate, + setHours, + setMinutes, + setMonth, + setSeconds, + setYear, +} from "date-fns"; + +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/datepicker/index.ts b/src/datepicker/index.ts new file mode 100644 index 000000000..dae7e50ae --- /dev/null +++ b/src/datepicker/index.ts @@ -0,0 +1,7 @@ +export * from "./DatePicker"; +export * from "./DatePickerContent"; +export * from "./DatePickerFieldState"; +export * from "./DatePickerState"; +export * from "./DatePickerTrigger"; +export * from "./DateSegment"; +export * from "./DateSegmentField"; diff --git a/src/datepicker/stories/DatePicker.stories.tsx b/src/datepicker/stories/DatePicker.stories.tsx index 3b309e2a6..f353b3ec4 100644 --- a/src/datepicker/stories/DatePicker.stories.tsx +++ b/src/datepicker/stories/DatePicker.stories.tsx @@ -3,14 +3,14 @@ import { Meta } from "@storybook/react"; import "./index.css"; import { + DatePicker, + DateSegment, + DateSegmentField, + DatePickerContent, + DatePickerTrigger, useDatePickerState, DatePickerStateInitialProps, -} from "../DatePickerState"; -import { DatePicker } from "../DatePicker"; -import { DateSegment } from "../DateSegment"; -import { DateSegmentField } from "../DateSegmentField"; -import { DatePickerContent } from "../DatePickerContent"; -import { DatePickerTrigger } from "../DatePickerTrigger"; +} from ".."; import { CalendarComponent } from "../../calendar-v1/stories/CalendarComponent"; export default { @@ -52,12 +52,7 @@ const CalendarIcon = () => ( ); -export const Default = () => ( - <> - - - -); +export const Default = () => ; export const InitialDate = () => ( ); From 28aec66dcecec27ab3aa1f31e8a747712a38a01a Mon Sep 17 00:00:00 2001 From: Anurag Date: Thu, 1 Oct 2020 16:08:34 +0530 Subject: [PATCH 12/23] fix: datepicker on open focus on selected date --- src/datepicker/DatePickerState.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/datepicker/DatePickerState.ts b/src/datepicker/DatePickerState.ts index 73fd3c32c..b48685915 100644 --- a/src/datepicker/DatePickerState.ts +++ b/src/datepicker/DatePickerState.ts @@ -38,6 +38,7 @@ export const useDatePickerState = (props: DatePickerStateInitialProps = {}) => { React.useEffect(() => { if (disclosure.visible) { calendar.setFocused(true); + calendar.setFocusedDate(date); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [disclosure.visible]); From e17320ed5d50bfc1ca8f915b01668ca9c58a4002 Mon Sep 17 00:00:00 2001 From: Anurag Date: Thu, 1 Oct 2020 17:56:10 +0530 Subject: [PATCH 13/23] chore: fixed calendar types & dateSegment composite spread --- src/calendar/CalendarState.ts | 14 ++------------ src/datepicker/DatePickerFieldState.ts | 14 ++++---------- src/datepicker/DateSegmentField.ts | 10 ++++++---- 3 files changed, 12 insertions(+), 26 deletions(-) diff --git a/src/calendar/CalendarState.ts b/src/calendar/CalendarState.ts index fd13ee7f3..a1eb595c8 100644 --- a/src/calendar/CalendarState.ts +++ b/src/calendar/CalendarState.ts @@ -32,18 +32,8 @@ import { announce } from "../utils/LiveAnnouncer"; import { isInvalid, useWeekDays, generateDaysInMonthArray } from "./__utils"; export type DateValue = string | number | Date; -export interface CalendarStateInitialProps { - minValue?: DateValue; - maxValue?: DateValue; - isDisabled?: boolean; - isReadOnly?: boolean; - autoFocus?: boolean; - /** The current value (controlled). */ - value?: DateValue; - /** The default value (uncontrolled). */ - defaultValue?: DateValue; - /** Handler that is called when the value changes. */ - onChange?: (value: DateValue) => void; + +export interface CalendarStateInitialProps extends Partial { id?: string; } diff --git a/src/datepicker/DatePickerFieldState.ts b/src/datepicker/DatePickerFieldState.ts index c982e3e0a..2702bb81c 100644 --- a/src/datepicker/DatePickerFieldState.ts +++ b/src/datepicker/DatePickerFieldState.ts @@ -1,13 +1,7 @@ -/* - * 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. +/** + * 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 { useMemo, useState } from "react"; diff --git a/src/datepicker/DateSegmentField.ts b/src/datepicker/DateSegmentField.ts index e20128eb6..65ca49bb8 100644 --- a/src/datepicker/DateSegmentField.ts +++ b/src/datepicker/DateSegmentField.ts @@ -1,7 +1,8 @@ -import { CompositeHTMLProps, CompositeOptions, useComposite } from "reakit"; -import { createComponent, createHook } from "reakit-system"; import { createOnKeyDown } from "reakit-utils"; +import { createComponent, createHook } from "reakit-system"; + import { DatePickerStateReturn } from "./DatePickerState"; +import { CompositeHTMLProps, CompositeOptions, useComposite } from "reakit"; import { DATE_SEGMENT_FIELD_KEYS } from "./__keys"; @@ -39,11 +40,12 @@ export const useDateSegmentField = createHook< }; }, }); - return { composite, onKeyDown, ...htmlProps }; + + return { ...composite, onKeyDown }; }, useProps(options, htmlProps) { - return { ...htmlProps }; + return htmlProps; }, }); From e9ae4873188150f5593e86701a0741b7186bb0b9 Mon Sep 17 00:00:00 2001 From: Navin Moorthy Date: Tue, 6 Oct 2020 18:33:31 +0530 Subject: [PATCH 14/23] feat: datepicker v3 (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(datepicker): ♻️ organize imports and improve code flow * feat(datepicker): ✨ add controllable date picker * refactor(date-picker): ♻️ bring popover close focus back * fix(date-picker): 🐛 fix focus spin button on focus * fix(date-picker): 🐛 fix outside click moving focus to button * refactor(date-picker): ♻️ arrange imports * refactor(date-picker): 🏷️ update types for Date Segment * fix(date-picker): 🐛 fix focus movements * fix(date-picker): 🐛 fix focus & date values * fix: calendar day value lag & few css improvements Co-authored-by: Anurag --- src/calendar/__utils.ts | 2 +- src/calendar/stories/Calendar.stories.tsx | 24 +- src/calendar/stories/CalendarComponent.tsx | 17 +- src/calendar/stories/index.css | 1 - src/datepicker/DatePicker.ts | 85 ++++++- src/datepicker/DatePickerContent.ts | 17 +- src/datepicker/DatePickerFieldState.ts | 57 ++--- src/datepicker/DatePickerState.ts | 128 ++++++++--- src/datepicker/DatePickerTrigger.ts | 24 +- src/datepicker/DateSegment.ts | 217 ++++++++++-------- src/datepicker/DateSegmentField.ts | 35 +-- src/datepicker/__keys.ts | 32 ++- src/datepicker/__utils.ts | 26 +++ src/datepicker/index.d.ts | 50 +--- src/datepicker/index.ts | 6 +- src/datepicker/stories/DatePicker.stories.tsx | 34 ++- src/datepicker/stories/index.css | 6 + 17 files changed, 483 insertions(+), 278 deletions(-) diff --git a/src/calendar/__utils.ts b/src/calendar/__utils.ts index 1ce39bf91..7fbf387b3 100644 --- a/src/calendar/__utils.ts +++ b/src/calendar/__utils.ts @@ -40,7 +40,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]; }, diff --git a/src/calendar/stories/Calendar.stories.tsx b/src/calendar/stories/Calendar.stories.tsx index 7189f57f2..c2cc08d53 100644 --- a/src/calendar/stories/Calendar.stories.tsx +++ b/src/calendar/stories/Calendar.stories.tsx @@ -3,22 +3,16 @@ import { Meta } from "@storybook/react"; import { addDays, addWeeks, subWeeks } from "date-fns"; import "./index.css"; +import { DateValue } from "../index"; import { CalendarComponent } from "./CalendarComponent"; -import { CalendarStateInitialProps, useCalendarState, DateValue } from ".."; export default { title: "Component/Calendar", } as Meta; -const CalendarComp: React.FC = props => { - const state = useCalendarState(props); - - return ; -}; - -export const Default = () => ; +export const Default = () => ; export const DefaultValue = () => ( - + ); export const ControlledValue = () => { const [value, setValue] = React.useState(addDays(new Date(), 1)); @@ -30,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 index ef3e43c8c..1b582196d 100644 --- a/src/calendar/stories/CalendarComponent.tsx +++ b/src/calendar/stories/CalendarComponent.tsx @@ -1,4 +1,6 @@ import React from "react"; + +import "./index.css"; import { Calendar as CalendarWrapper, CalendarButton, @@ -6,12 +8,13 @@ import { CalendarCellButton, CalendarGrid, CalendarHeader, - CalendarStateReturn, CalendarWeekTitle, -} from ".."; -import "./index.css"; + CalendarStateInitialProps, + useCalendarState, +} from "../index"; +import { CalendarStateReturn } from "../CalendarState"; -export const CalendarComponent: React.FC = state => { +export const CalendarComp: React.FC = state => { return (
@@ -111,3 +114,9 @@ export const CalendarComponent: React.FC = state => { ); }; + +export const CalendarComponent: React.FC = props => { + const state = useCalendarState(props); + + return ; +}; diff --git a/src/calendar/stories/index.css b/src/calendar/stories/index.css index dc29ab141..2e9742a12 100644 --- a/src/calendar/stories/index.css +++ b/src/calendar/stories/index.css @@ -1,5 +1,4 @@ .calendar { - margin-top: 1em; max-width: 320px; position: relative; } diff --git a/src/datepicker/DatePicker.ts b/src/datepicker/DatePicker.ts index 571942cef..7d50d2edd 100644 --- a/src/datepicker/DatePicker.ts +++ b/src/datepicker/DatePicker.ts @@ -1,22 +1,101 @@ +import { createOnKeyDown } from "reakit-utils"; import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; import { createComponent, createHook } from "reakit-system"; +import { ariaAttr, callAllHandlers } from "@chakra-ui/utils"; import { DATE_PICKER_KEYS } from "./__keys"; +import { DatePickerStateReturn } from "./DatePickerState"; -export type DatePickerOptions = BoxOptions; +export type DatePickerOptions = BoxOptions & + Pick< + DatePickerStateReturn, + | "visible" + | "validationState" + | "isDisabled" + | "isReadOnly" + | "isRequired" + | "show" + | "pickerId" + | "dialogId" + | "first" + >; 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, htmlProps) { - return htmlProps; + 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, + }; }, }, ); diff --git a/src/datepicker/DatePickerContent.ts b/src/datepicker/DatePickerContent.ts index 770e219f6..65ca9ff98 100644 --- a/src/datepicker/DatePickerContent.ts +++ b/src/datepicker/DatePickerContent.ts @@ -1,9 +1,12 @@ 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; +export type DatePickerContentOptions = PopoverOptions & + Pick; export type DatePickerContentHTMLProps = PopoverHTMLProps; @@ -18,8 +21,16 @@ export const useDatePickerContent = createHook< compose: usePopover, keys: DATE_PICKER_CONTENT_KEYS, - useProps(options, htmlProps) { - return htmlProps; + useProps({ dialogId }, { onMouseDown: htmlOnMouseDown, ...htmlProps }) { + const onMouseDown = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + return { + id: dialogId, + onMouseDown: callAllHandlers(htmlOnMouseDown, onMouseDown), + ...htmlProps, + }; }, }); diff --git a/src/datepicker/DatePickerFieldState.ts b/src/datepicker/DatePickerFieldState.ts index 2702bb81c..241d84cf9 100644 --- a/src/datepicker/DatePickerFieldState.ts +++ b/src/datepicker/DatePickerFieldState.ts @@ -1,14 +1,14 @@ /** * 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) + * We improved the Calendar from Stately [useDatePickerFieldState](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/datepicker/src/useDatePickerFieldState.ts) * to work with Reakit System */ import { useMemo, useState } from "react"; import { useDateFormatter } from "@react-aria/i18n"; -import { useControlledState } from "@react-stately/utils"; +import { useControllableState } from "@chakra-ui/hooks"; -import { DatePickerProps } from "./index.d"; +import { DatePickerStateInitialProps } from "./index.d"; import { add, setSegment, convertValue, getSegmentLimits } from "./__utils"; export interface IDateSegment { @@ -20,19 +20,6 @@ export interface IDateSegment { isPlaceholder: boolean; } -export interface DatePickerFieldState { - value: Date; - setValue: (value: Date) => void; - segments: IDateSegment[]; - dateFormatter: Intl.DateTimeFormat; - increment: (type: Intl.DateTimeFormatPartTypes) => void; - decrement: (type: Intl.DateTimeFormatPartTypes) => void; - incrementPage: (type: Intl.DateTimeFormatPartTypes) => void; - decrementPage: (type: Intl.DateTimeFormatPartTypes) => void; - setSegment: (type: Intl.DateTimeFormatPartTypes, value: number) => void; - confirmPlaceholder: (type: Intl.DateTimeFormatPartTypes) => void; -} - const EDITABLE_SEGMENTS = { year: true, month: true, @@ -57,9 +44,7 @@ const TYPE_MAPPING = { dayperiod: "dayPeriod", }; -export function useDatePickerFieldState( - props: DatePickerProps, -): DatePickerFieldState { +export function useDatePickerFieldState(props: DatePickerStateInitialProps) { const [validSegments, setValidSegments] = useState( props.value || props.defaultValue ? { ...EDITABLE_SEGMENTS } : {}, ); @@ -92,14 +77,15 @@ export function useDatePickerFieldState( 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, - ); + const [date, setDate] = useControllableState({ + value: + props.value == null + ? convertValue(placeholderDate) + : convertValue(props.value), + defaultValue: convertValue(props.defaultValue), + onChange: props.onChange, + shouldUpdate: (prev, next) => prev !== next, + }); // If all segments are valid, use the date from state, otherwise use the placeholder date. const value = @@ -128,34 +114,33 @@ export function useDatePickerFieldState( ) => { validSegments[type] = true; setValidSegments({ ...validSegments }); - // @ts-ignore setValue(add(value, type, amount, resolvedOptions)); }; return { - value, - setValue, + fieldValue: value, + setFieldValue: setValue, segments, dateFormatter, - increment(part) { + increment(part: Intl.DateTimeFormatPartTypes) { adjustSegment(part, 1); }, - decrement(part) { + decrement(part: Intl.DateTimeFormatPartTypes) { adjustSegment(part, -1); }, - incrementPage(part) { + incrementPage(part: Intl.DateTimeFormatPartTypes) { adjustSegment(part, PAGE_STEP[part] || 1); }, - decrementPage(part) { + decrementPage(part: Intl.DateTimeFormatPartTypes) { adjustSegment(part, -(PAGE_STEP[part] || 1)); }, - setSegment(part, v) { + setSegment(part: Intl.DateTimeFormatPartTypes, v: number) { validSegments[part] = true; setValidSegments({ ...validSegments }); // @ts-ignore setValue(setSegment(value, part, v, resolvedOptions)); }, - confirmPlaceholder(part) { + confirmPlaceholder(part: Intl.DateTimeFormatPartTypes) { validSegments[part] = true; setValidSegments({ ...validSegments }); setValue(new Date(value)); diff --git a/src/datepicker/DatePickerState.ts b/src/datepicker/DatePickerState.ts index b48685915..0f5ffcc6f 100644 --- a/src/datepicker/DatePickerState.ts +++ b/src/datepicker/DatePickerState.ts @@ -1,53 +1,119 @@ -import React from "react"; -import { useCompositeState, useDisclosureState } from "reakit"; +/** + * 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 { useDatePickerFieldState } from "./DatePickerFieldState"; -import { useCalendarState, CalendarStateInitialProps } from "../calendar"; +import * as React from "react"; +import { isValid } from "date-fns"; +import { useControllableState } from "@chakra-ui/hooks"; +import { + useCompositeState, + usePopoverState, + unstable_useId as useId, +} from "reakit"; -export interface DatePickerStateInitialProps extends CalendarStateInitialProps { - visible?: boolean; - initialDate?: Date; - dateFormat?: Intl.DateTimeFormatOptions; -} +import { setTime, isInvalid } from "./__utils"; +import { DateValue, useCalendarState } from "../calendar"; +import { useDatePickerFieldState } from "./DatePickerFieldState"; +import { DatePickerStateInitialProps, ValidationState } from "./index.d"; export const useDatePickerState = (props: DatePickerStateInitialProps = {}) => { - const { visible, dateFormat, initialDate = new Date() } = props; + const { + value: initialDate, + defaultValue: defaultValueProp, + onChange, + minValue: minValueProp, + maxValue: maxValueProp, + isDisabled, + isReadOnly, + isRequired, + pickerId: pickerIdProp, + dialogId: dialogIdProp, + formatOptions, + placeholderDate: placeholderDateProp, + } = props; - const [date, setDate] = React.useState(initialDate); + const { id: pickerId } = useId({ id: pickerIdProp, baseId: "picker" }); + const { id: dialogId } = useId({ id: dialogIdProp, baseId: "dialog" }); - const segmentComposite = useCompositeState({ orientation: "horizontal" }); - const disclosure = useDisclosureState({ visible }); + const defaultValue = + defaultValueProp && isValid(defaultValueProp) + ? new Date(defaultValueProp) + : new Date(); - const calendar = useCalendarState({ - value: date, - onChange: date => { - setDate(new Date(date)); - disclosure.hide(); - }, + 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 composite = useCompositeState({ orientation: "horizontal" }); const fieldState = useDatePickerFieldState({ - formatOptions: dateFormat, - value: date, - onChange(v) { - setDate(new Date(v)); - calendar.focusCell(new Date(v)); - }, + 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 (disclosure.visible) { + if (popover.visible) { calendar.setFocused(true); - calendar.setFocusedDate(date); + dateValue && calendar.focusCell(dateValue); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [disclosure.visible]); + }, [popover.visible]); return { - calendar, + pickerId, + dialogId, + dateValue, + setDateValue: setValue, + selectDate, + validationState, + minValue, + maxValue, + isDisabled, + isReadOnly, + isRequired, + ...composite, + ...popover, ...fieldState, - ...segmentComposite, - ...disclosure, + calendar, }; }; diff --git a/src/datepicker/DatePickerTrigger.ts b/src/datepicker/DatePickerTrigger.ts index 74b321812..65eece38d 100644 --- a/src/datepicker/DatePickerTrigger.ts +++ b/src/datepicker/DatePickerTrigger.ts @@ -1,10 +1,14 @@ -import { usePopoverDisclosure, PopoverDisclosureHTMLProps } from "reakit"; +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 { createComponent, createHook } from "reakit-system"; -// TODO: Fix Typescript error -export type DatePickerTriggerOptions = any; // PopoverDisclosureOptions; +export type DatePickerTriggerOptions = PopoverDisclosureOptions; export type DatePickerTriggerHTMLProps = PopoverDisclosureHTMLProps; @@ -19,8 +23,16 @@ export const useDatePickerTrigger = createHook< compose: usePopoverDisclosure, keys: DATE_PICKER_TRIGGER_KEYS, - useProps(options, htmlProps) { - return { tabIndex: -1, ...htmlProps }; + useProps(_, { onMouseDown: htmlOnMouseDown, ...htmlProps }) { + const onMouseDown = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + return { + tabIndex: -1, + onMouseDown: callAllHandlers(htmlOnMouseDown, onMouseDown), + ...htmlProps, + }; }, }); diff --git a/src/datepicker/DateSegment.ts b/src/datepicker/DateSegment.ts index ab60edcf4..ee256a262 100644 --- a/src/datepicker/DateSegment.ts +++ b/src/datepicker/DateSegment.ts @@ -1,31 +1,47 @@ +import { useState } from "react"; +import { DOMProps } from "@react-types/shared"; +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 { - useBox, - BoxOptions, BoxHTMLProps, useCompositeItem, CompositeItemOptions, CompositeItemHTMLProps, + unstable_useId as useId, } from "reakit"; -import { MouseEvent, useState } from "react"; -import { DOMProps } from "@react-types/shared"; -import { useDateFormatter } from "@react-aria/i18n"; -import { mergeProps, useId } from "@react-aria/utils"; -import { createComponent, createHook } from "reakit-system"; -import { DatePickerProps } from "./index.d"; import { DATE_SEGMENT_KEYS } from "./__keys"; import { isNumeric, parseNumber } from "./__utils"; import { useSpinButton } from "../utils/useSpinButton"; -import { DatePickerFieldState, IDateSegment } from "./DatePickerFieldState"; +import { DatePickerStateReturn } from "./DatePickerState"; +import { + DatePickerFieldStateReturn, + IDateSegment, +} from "./DatePickerFieldState"; export type DateSegmentOptions = CompositeItemOptions & - BoxOptions & - DatePickerFieldState & - DatePickerProps & { + Pick< + DatePickerFieldStateReturn, + | "fieldValue" + | "increment" + | "incrementPage" + | "setSegment" + | "decrement" + | "decrementPage" + | "confirmPlaceholder" + > & + Pick< + DatePickerStateReturn, + | "pickerId" + | "next" + | "dateFormatter" + | "isDisabled" + | "isRequired" + | "isReadOnly" + > & { segment: IDateSegment; - isDisabled?: boolean; - isReadOnly?: boolean; - isRequired?: boolean; }; export type DateSegmentHTMLProps = CompositeItemHTMLProps & @@ -39,7 +55,7 @@ export const useDateSegment = createHook< DateSegmentHTMLProps >({ name: "DateSegment", - compose: [useBox, useCompositeItem], + compose: [useCompositeItem], keys: DATE_SEGMENT_KEYS, useOptions(options, htmlProps) { @@ -49,37 +65,28 @@ export const useDateSegment = createHook< }; }, - 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 { - ...htmlProps, - ...composite, - tabIndex: options.segment.type === "literal" ? -1 : 0, - }; - }, - - useProps({ segment, next, previous, ...state }, htmlProps) { + useProps( + { segment, next, ...options }, + { + onKeyDown: htmlOnKeyDown, + onFocus: htmlOnFocus, + onMouseDown: htmlOnMouseDown, + ...htmlProps + }, + ) { const [enteredKeys, setEnteredKeys] = useState(""); let textValue = segment.text; const monthDateFormatter = useDateFormatter({ month: "long" }); const hourDateFormatter = useDateFormatter({ hour: "numeric", - hour12: state.dateFormatter.resolvedOptions().hour12, + hour12: options.dateFormatter.resolvedOptions().hour12, }); if (segment.type === "month") { - textValue = monthDateFormatter.format(state.value); + textValue = monthDateFormatter.format(options.fieldValue); } else if (segment.type === "hour" || segment.type === "dayPeriod") { - textValue = hourDateFormatter.format(state.value); + textValue = hourDateFormatter.format(options.fieldValue); } const { spinButtonProps } = useSpinButton({ @@ -87,66 +94,28 @@ export const useDateSegment = createHook< textValue, minValue: segment.minValue, maxValue: segment.maxValue, - isDisabled: state.isDisabled, - isReadOnly: state.isReadOnly, - isRequired: state.isRequired, - onIncrement: () => state.increment(segment.type), - onDecrement: () => state.decrement(segment.type), - onIncrementPage: () => state.incrementPage(segment.type), - onDecrementPage: () => state.decrementPage(segment.type), + 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: () => - state.setSegment(segment.type, segment.maxValue as number), + options.setSegment(segment.type, segment.maxValue as number), onDecrementToMin: () => - state.setSegment(segment.type, segment.minValue as number), + 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 && !state.isReadOnly) { - state.confirmPlaceholder(segment.type); - } - next(); - break; - case "Tab": - break; - case "Backspace": { - e.preventDefault(); - if (isNumeric(segment.text) && !state.isReadOnly) { - const newValue = segment.text.slice(0, -1); - state.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)) && !state.isReadOnly) { - onInput(e.key); - } - } - }; - const onInput = (key: string) => { const newValue = enteredKeys + key; switch (segment.type) { case "dayPeriod": if (key === "a") { - state.setSegment("dayPeriod", 0); + options.setSegment("dayPeriod", 0); } else if (key === "p") { - state.setSegment("dayPeriod", 12); + options.setSegment("dayPeriod", 12); } next(); break; @@ -164,7 +133,7 @@ export const useDateSegment = createHook< let segmentValue = numberValue; if ( segment.type === "hour" && - state.dateFormatter.resolvedOptions().hour12 && + options.dateFormatter.resolvedOptions().hour12 && numberValue === 12 ) { segmentValue = 0; @@ -172,7 +141,7 @@ export const useDateSegment = createHook< segmentValue = parseNumber(key); } - state.setSegment(segment.type, segmentValue); + options.setSegment(segment.type, segmentValue); if (Number(numberValue + "0") > (segment.maxValue as number)) { setEnteredKeys(""); @@ -185,11 +154,56 @@ export const useDateSegment = createHook< } }; + 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 onFocus = () => { setEnteredKeys(""); }; - const id = useId(htmlProps.id); + const onMouseDown = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + const { id } = useId({ baseId: "spin-button" }); switch (segment.type) { // A separator, e.g. punctuation @@ -217,16 +231,31 @@ export const useDateSegment = createHook< return mergeProps(spinButtonProps, { id, "aria-label": segment.type, - "aria-labelledby": `${state["aria-labelledby"]} ${id}`, - tabIndex: state.isDisabled ? undefined : 0, - onKeyDown, - onFocus, - onMouseDown: (e: MouseEvent) => e.stopPropagation(), + "aria-labelledby": `${options.pickerId} ${id}`, + onKeyDown: callAllHandlers(htmlOnKeyDown, onKeyDown), + onFocus: callAllHandlers(htmlOnFocus, onFocus), + onMouseDown: callAllHandlers(htmlOnMouseDown, onMouseDown), children: segment.text, ...htmlProps, }); } }, + + 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, + }; + }, }); export const DateSegment = createComponent({ diff --git a/src/datepicker/DateSegmentField.ts b/src/datepicker/DateSegmentField.ts index 65ca49bb8..71dacb263 100644 --- a/src/datepicker/DateSegmentField.ts +++ b/src/datepicker/DateSegmentField.ts @@ -1,13 +1,11 @@ -import { createOnKeyDown } from "reakit-utils"; import { createComponent, createHook } from "reakit-system"; - -import { DatePickerStateReturn } from "./DatePickerState"; import { CompositeHTMLProps, CompositeOptions, useComposite } from "reakit"; import { DATE_SEGMENT_FIELD_KEYS } from "./__keys"; +import { DatePickerStateReturn } from "./DatePickerState"; export type DateSegmentFieldOptions = CompositeOptions & - Pick; + Pick; export type DateSegmentFieldHTMLProps = CompositeHTMLProps; @@ -22,30 +20,11 @@ export const useDateSegmentField = createHook< compose: useComposite, keys: DATE_SEGMENT_FIELD_KEYS, - useComposeProps(options, htmlProps) { - const composite = useComposite(options, htmlProps); - const onKeyDown = createOnKeyDown({ - onKey: composite.onKeyDown, - preventDefault: false, - keyMap: event => { - const isShift = event.shiftKey; - const isAlt = event.altKey; - return { - Tab: () => { - isShift ? options.previous() : options.next(); - }, - ArrowDown: () => { - isAlt && options.show(); - }, - }; - }, - }); - - return { ...composite, onKeyDown }; - }, - - useProps(options, htmlProps) { - return htmlProps; + useProps(options, { onKeyDown: htmlOnKeyDown, ...htmlProps }) { + return { + "aria-labelledby": options.pickerId, + ...htmlProps, + }; }, }); diff --git a/src/datepicker/__keys.ts b/src/datepicker/__keys.ts index 0e3c90ea6..2d6be80fc 100644 --- a/src/datepicker/__keys.ts +++ b/src/datepicker/__keys.ts @@ -1,7 +1,7 @@ // Automatically generated const DATE_PICKER_FIELD_STATE_KEYS = [ - "value", - "setValue", + "fieldValue", + "setFieldValue", "segments", "dateFormatter", "increment", @@ -13,6 +13,7 @@ const DATE_PICKER_FIELD_STATE_KEYS = [ ] as const; const DATE_PICKER_STATE_KEYS = [ ...DATE_PICKER_FIELD_STATE_KEYS, + "calendar", "baseId", "unstable_idCountRef", "visible", @@ -25,6 +26,18 @@ const DATE_PICKER_STATE_KEYS = [ "setVisible", "setAnimated", "stopAnimation", + "modal", + "unstable_disclosureRef", + "setModal", + "unstable_referenceRef", + "unstable_popoverRef", + "unstable_arrowRef", + "unstable_popoverStyles", + "unstable_arrowStyles", + "unstable_originalPlacement", + "unstable_update", + "placement", + "place", "unstable_virtual", "rtl", "orientation", @@ -56,7 +69,17 @@ const DATE_PICKER_STATE_KEYS = [ "setWrap", "reset", "unstable_setHasActiveWidget", - "calendar", + "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; @@ -64,8 +87,5 @@ export const DATE_PICKER_TRIGGER_KEYS = DATE_PICKER_CONTENT_KEYS; export const DATE_SEGMENT_KEYS = [ ...DATE_PICKER_TRIGGER_KEYS, "segment", - "isDisabled", - "isReadOnly", - "isRequired", ] as const; export const DATE_SEGMENT_FIELD_KEYS = DATE_PICKER_TRIGGER_KEYS; diff --git a/src/datepicker/__utils.ts b/src/datepicker/__utils.ts index 86d3dc3e6..8a84bed27 100644 --- a/src/datepicker/__utils.ts +++ b/src/datepicker/__utils.ts @@ -126,6 +126,9 @@ export function add( const year = cycleValue(getYear(value), amount, 1, 9999, true); return setYear(value, year); } + default: { + return value; + } } } @@ -230,3 +233,26 @@ export function parseNumber(str: string): number { export function isNumeric(str: string) { return /^[0-9\u0660-\u0669\u06f0-\u06f9]+$/.test(str); } + +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 index c950c5541..add6c678a 100644 --- a/src/datepicker/index.d.ts +++ b/src/datepicker/index.d.ts @@ -1,56 +1,24 @@ -/* - * 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 { PopoverInitialState } from "reakit"; import { - DOMProps, - FocusableProps, InputBase, - LabelableProps, - RangeValue, - SpectrumLabelableProps, - StyleProps, Validation, + FocusableProps, ValueBase, } from "@react-types/shared"; export type DateValue = string | number | Date; -interface DatePickerBase - extends InputBase, - Validation, - FocusableProps, - LabelableProps { +interface DatePickerBase extends InputBase, Validation, FocusableProps { minValue?: DateValue; maxValue?: DateValue; formatOptions?: Intl.DateTimeFormatOptions; placeholderDate?: DateValue; + pickerId?: string; + dialogId?: string; } -export interface DatePickerProps extends DatePickerBase, ValueBase {} - -export type DateRange = RangeValue; -export interface DateRangePickerProps +export interface DatePickerStateInitialProps extends DatePickerBase, - ValueBase {} - -interface SpectrumDatePickerBase - extends SpectrumLabelableProps, - DOMProps, - StyleProps { - isQuiet?: boolean; -} + PopoverInitialState, + ValueBase {} -export interface SpectrumDatePickerProps - extends DatePickerProps, - SpectrumDatePickerBase {} -export interface SpectrumDateRangePickerProps - extends DateRangePickerProps, - SpectrumDatePickerBase {} +export { ValidationState } from "@react-types/shared"; diff --git a/src/datepicker/index.ts b/src/datepicker/index.ts index dae7e50ae..4fc0af391 100644 --- a/src/datepicker/index.ts +++ b/src/datepicker/index.ts @@ -1,7 +1,7 @@ -export * from "./DatePicker"; -export * from "./DatePickerContent"; -export * from "./DatePickerFieldState"; export * from "./DatePickerState"; +export * from "./DatePickerFieldState"; +export * from "./DatePicker"; export * from "./DatePickerTrigger"; +export * from "./DatePickerContent"; export * from "./DateSegment"; export * from "./DateSegmentField"; diff --git a/src/datepicker/stories/DatePicker.stories.tsx b/src/datepicker/stories/DatePicker.stories.tsx index 1f14d8928..7440f00a8 100644 --- a/src/datepicker/stories/DatePicker.stories.tsx +++ b/src/datepicker/stories/DatePicker.stories.tsx @@ -1,7 +1,10 @@ import * as React from "react"; +import { addDays } from "date-fns"; import { Meta } from "@storybook/react"; import "./index.css"; +import { DatePickerStateInitialProps, DateValue } from "../index.d"; +import { CalendarComp } from "../../calendar/stories/CalendarComponent"; import { DatePicker, DateSegment, @@ -9,16 +12,19 @@ import { DatePickerContent, DatePickerTrigger, useDatePickerState, - DatePickerStateInitialProps, -} from ".."; -import { CalendarComponent } from "../../calendar/stories/CalendarComponent"; +} from "../index"; export default { title: "Component/DatePicker", } as Meta; const DatePickerComp: React.FC = props => { - const state = useDatePickerState(props); + const state = useDatePickerState({ + formatOptions: { month: "2-digit", day: "2-digit", year: "numeric" }, + ...props, + }); + + console.log("%c state", "color: #f27999", state); return ( @@ -39,7 +45,7 @@ const DatePickerComp: React.FC = props => {
- + ); @@ -53,6 +59,22 @@ 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)} + /> + +
+ ); +}; diff --git a/src/datepicker/stories/index.css b/src/datepicker/stories/index.css index 2b74c5415..f4df0c8dd 100644 --- a/src/datepicker/stories/index.css +++ b/src/datepicker/stories/index.css @@ -18,9 +18,15 @@ 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; } .datepicker__field { + font-family: monospace; display: flex; } From aee6fc0b066fc2092294c2cd9362a1439546ed50 Mon Sep 17 00:00:00 2001 From: Anurag Date: Tue, 6 Oct 2020 19:36:52 +0530 Subject: [PATCH 15/23] fix: range calendar start date iso timing lag --- src/calendar/__utils.ts | 2 +- src/calendar/stories/RangeCalendar.stories.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/calendar/__utils.ts b/src/calendar/__utils.ts index 7fbf387b3..41604f098 100644 --- a/src/calendar/__utils.ts +++ b/src/calendar/__utils.ts @@ -58,7 +58,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/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 (
Date: Wed, 7 Oct 2020 13:14:52 +0530 Subject: [PATCH 16/23] chore: datepicker stories improvements (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(date-picker): ♻️ added more stories * refactor(date-picker): ♻️ add disabled styles --- src/datepicker/DatePicker.ts | 2 +- src/datepicker/DatePickerState.ts | 8 +++++ src/datepicker/DatePickerTrigger.ts | 11 ++++++- src/datepicker/DateSegment.ts | 5 ++- src/datepicker/stories/DatePicker.stories.tsx | 31 ++++++++++++++++--- src/datepicker/stories/index.css | 9 ++++++ 6 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/datepicker/DatePicker.ts b/src/datepicker/DatePicker.ts index 7d50d2edd..e6fd1a2ab 100644 --- a/src/datepicker/DatePicker.ts +++ b/src/datepicker/DatePicker.ts @@ -1,7 +1,7 @@ import { createOnKeyDown } from "reakit-utils"; import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; import { createComponent, createHook } from "reakit-system"; -import { ariaAttr, callAllHandlers } from "@chakra-ui/utils"; +import { ariaAttr, callAllHandlers, dataAttr } from "@chakra-ui/utils"; import { DATE_PICKER_KEYS } from "./__keys"; import { DatePickerStateReturn } from "./DatePickerState"; diff --git a/src/datepicker/DatePickerState.ts b/src/datepicker/DatePickerState.ts index 0f5ffcc6f..802a147ed 100644 --- a/src/datepicker/DatePickerState.ts +++ b/src/datepicker/DatePickerState.ts @@ -28,6 +28,7 @@ export const useDatePickerState = (props: DatePickerStateInitialProps = {}) => { isDisabled, isReadOnly, isRequired, + autoFocus, pickerId: pickerIdProp, dialogId: dialogIdProp, formatOptions, @@ -98,6 +99,13 @@ export const useDatePickerState = (props: DatePickerStateInitialProps = {}) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [popover.visible]); + React.useEffect(() => { + if (autoFocus) { + composite.first(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoFocus, composite.first]); + return { pickerId, dialogId, diff --git a/src/datepicker/DatePickerTrigger.ts b/src/datepicker/DatePickerTrigger.ts index 65eece38d..f4c7ea03a 100644 --- a/src/datepicker/DatePickerTrigger.ts +++ b/src/datepicker/DatePickerTrigger.ts @@ -7,8 +7,10 @@ import { } from "reakit"; import { DATE_PICKER_TRIGGER_KEYS } from "./__keys"; +import { DatePickerStateReturn } from "./DatePickerState"; -export type DatePickerTriggerOptions = PopoverDisclosureOptions; +export type DatePickerTriggerOptions = PopoverDisclosureOptions & + Pick; export type DatePickerTriggerHTMLProps = PopoverDisclosureHTMLProps; @@ -23,6 +25,13 @@ export const useDatePickerTrigger = createHook< 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(); diff --git a/src/datepicker/DateSegment.ts b/src/datepicker/DateSegment.ts index ee256a262..7da764e89 100644 --- a/src/datepicker/DateSegment.ts +++ b/src/datepicker/DateSegment.ts @@ -60,7 +60,10 @@ export const useDateSegment = createHook< useOptions(options, htmlProps) { return { - disabled: options.segment.type === "literal", + disabled: + options.isDisabled || + options.isReadOnly || + options.segment.type === "literal", ...options, }; }, diff --git a/src/datepicker/stories/DatePicker.stories.tsx b/src/datepicker/stories/DatePicker.stories.tsx index 7440f00a8..644dff29c 100644 --- a/src/datepicker/stories/DatePicker.stories.tsx +++ b/src/datepicker/stories/DatePicker.stories.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { addDays } from "date-fns"; import { Meta } from "@storybook/react"; +import { addDays, addWeeks, subWeeks } from "date-fns"; import "./index.css"; import { DatePickerStateInitialProps, DateValue } from "../index.d"; @@ -24,10 +24,8 @@ const DatePickerComp: React.FC = props => { ...props, }); - console.log("%c state", "color: #f27999", state); - return ( - +
{state.segments.map((segment, i) => ( @@ -78,3 +76,28 @@ export const ControllableState = () => {
); }; + +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 index f4df0c8dd..1c71892a4 100644 --- a/src/datepicker/stories/index.css +++ b/src/datepicker/stories/index.css @@ -25,6 +25,10 @@ border: 1px solid #1e65fd; } +[aria-invalid="true"] > .datepicker__header { + border: 1px solid #c00000; +} + .datepicker__field { font-family: monospace; display: flex; @@ -34,8 +38,13 @@ padding: 2px; border-radius: 4px; } + .datepicker__field--item:focus { background-color: #1e65fd; color: white; outline: none; } + +.datepicker [aria-disabled="true"] { + opacity: 0.5; +} From e6802c7e3f732f215dae84a0008cb5ed0dd8415b Mon Sep 17 00:00:00 2001 From: Anurag Hazra Date: Wed, 7 Oct 2020 13:24:16 +0530 Subject: [PATCH 17/23] feat: added segment component (#71) * feat: added Segment component * refactor: review updates & segment aria label fix * chore: event listener rename --- src/segment-spinner/Segment.ts | 249 ++++++++++++++++++ src/segment-spinner/SegmentField.ts | 30 +++ src/segment-spinner/SegmentState.ts | 165 ++++++++++++ src/segment-spinner/__keys.ts | 55 ++++ src/segment-spinner/__utils.ts | 233 ++++++++++++++++ .../stories/SegmentSpinner.stories.tsx | 70 +++++ src/segment-spinner/stories/index.css | 21 ++ 7 files changed, 823 insertions(+) create mode 100644 src/segment-spinner/Segment.ts create mode 100644 src/segment-spinner/SegmentField.ts create mode 100644 src/segment-spinner/SegmentState.ts create mode 100644 src/segment-spinner/__keys.ts create mode 100644 src/segment-spinner/__utils.ts create mode 100644 src/segment-spinner/stories/SegmentSpinner.stories.tsx create mode 100644 src/segment-spinner/stories/index.css diff --git a/src/segment-spinner/Segment.ts b/src/segment-spinner/Segment.ts new file mode 100644 index 000000000..36e546c25 --- /dev/null +++ b/src/segment-spinner/Segment.ts @@ -0,0 +1,249 @@ +import { + useCompositeItem, + CompositeItemOptions, + CompositeItemHTMLProps, +} from "reakit"; +import { MouseEvent, useState } from "react"; +import { mergeProps, useId } from "@react-aria/utils"; +import { DOMProps } from "@react-types/shared"; +import { callAllHandlers } from "@chakra-ui/utils"; +import { useDateFormatter } from "@react-aria/i18n"; +import { createComponent, createHook } from "reakit-system"; + +import { isNumeric, parseNumber } from "./__utils"; +import { useSpinButton } from "../utils/useSpinButton"; +import { DATE_SEGMENT_KEYS } from "../datepicker/__keys"; +import { IDateSegment, SegmentStateReturn } from "./SegmentState"; + +export type SegmentOptions = CompositeItemOptions & + Pick< + SegmentStateReturn, + | "next" + | "dateFormatter" + | "confirmPlaceholder" + | "increment" + | "decrement" + | "incrementPage" + | "decrementPage" + | "setSegment" + | "value" + > & { + segment: IDateSegment; + isDisabled?: boolean; + isReadOnly?: boolean; + isRequired?: boolean; + }; + +export type SegmentHTMLProps = CompositeItemHTMLProps & DOMProps; + +export type SegmentProps = SegmentOptions & SegmentHTMLProps; + +export const useSegment = createHook({ + name: "Segment", + compose: useCompositeItem, + keys: DATE_SEGMENT_KEYS, + + useOptions(options, htmlProps) { + return { + disabled: 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.value); + } else if (segment.type === "hour" || segment.type === "dayPeriod") { + textValue = hourDateFormatter.format(options.value); + } + + 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(htmlProps.id); + + 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..af4200313 --- /dev/null +++ b/src/segment-spinner/SegmentField.ts @@ -0,0 +1,30 @@ +import { createComponent, createHook } from "reakit-system"; +import { CompositeHTMLProps, CompositeOptions, useComposite } from "reakit"; + +import { SEGMENT_FIELD_KEYS } from "./__keys"; +import { SegmentStateReturn } from "./SegmentState"; + +export type SegmentFieldOptions = CompositeOptions & SegmentStateReturn; + +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..f9ab985cd --- /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, + value, + 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..7e8ee8064 --- /dev/null +++ b/src/segment-spinner/__utils.ts @@ -0,0 +1,233 @@ +import { + getDate, + getDaysInMonth, + getHours, + getMinutes, + getMonth, + getSeconds, + getYear, + setDate, + setHours, + setMinutes, + setMonth, + setSeconds, + setYear, +} from "date-fns"; + +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); +} + +export type DateValue = string | number | Date; 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; +} From 9a9aa94aa06773dbe4a4371cde81e9dc0e264071 Mon Sep 17 00:00:00 2001 From: Anurag Date: Wed, 7 Oct 2020 13:58:04 +0530 Subject: [PATCH 18/23] feat: added Segment component to DatePicker --- src/datepicker/DatePickerFieldState.ts | 153 --------------- src/datepicker/DatePickerState.ts | 8 +- src/datepicker/DateSegment.ts | 252 +------------------------ src/datepicker/DateSegmentField.ts | 21 +-- src/datepicker/__keys.ts | 60 +++--- src/datepicker/__utils.ts | 234 ----------------------- src/datepicker/index.ts | 7 +- src/segment-spinner/Segment.ts | 22 ++- src/segment-spinner/SegmentField.ts | 3 +- 9 files changed, 61 insertions(+), 699 deletions(-) delete mode 100644 src/datepicker/DatePickerFieldState.ts diff --git a/src/datepicker/DatePickerFieldState.ts b/src/datepicker/DatePickerFieldState.ts deleted file mode 100644 index 241d84cf9..000000000 --- a/src/datepicker/DatePickerFieldState.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useDatePickerFieldState](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/datepicker/src/useDatePickerFieldState.ts) - * to work with Reakit System - */ - -import { useMemo, useState } from "react"; -import { useDateFormatter } from "@react-aria/i18n"; -import { useControllableState } from "@chakra-ui/hooks"; - -import { DatePickerStateInitialProps } from "./index.d"; -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 function useDatePickerFieldState(props: DatePickerStateInitialProps) { - const [validSegments, setValidSegments] = useState( - props.value || props.defaultValue ? { ...EDITABLE_SEGMENTS } : {}, - ); - - 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] = useControllableState({ - value: - props.value == null - ? convertValue(placeholderDate) - : convertValue(props.value), - defaultValue: convertValue(props.defaultValue), - onChange: props.onChange, - shouldUpdate: (prev, next) => prev !== next, - }); - - // 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 }); - setValue(add(value, type, amount, resolvedOptions)); - }; - - return { - 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 DatePickerFieldStateReturn = ReturnType< - typeof useDatePickerFieldState ->; diff --git a/src/datepicker/DatePickerState.ts b/src/datepicker/DatePickerState.ts index 802a147ed..fde108830 100644 --- a/src/datepicker/DatePickerState.ts +++ b/src/datepicker/DatePickerState.ts @@ -8,14 +8,14 @@ import * as React from "react"; import { isValid } from "date-fns"; import { useControllableState } from "@chakra-ui/hooks"; import { - useCompositeState, usePopoverState, + useCompositeState, unstable_useId as useId, } from "reakit"; import { setTime, isInvalid } from "./__utils"; import { DateValue, useCalendarState } from "../calendar"; -import { useDatePickerFieldState } from "./DatePickerFieldState"; +import { useSegmentState } from "../segment-spinner/SegmentState"; import { DatePickerStateInitialProps, ValidationState } from "./index.d"; export const useDatePickerState = (props: DatePickerStateInitialProps = {}) => { @@ -72,7 +72,7 @@ export const useDatePickerState = (props: DatePickerStateInitialProps = {}) => { const popover = usePopoverState(props); const composite = useCompositeState({ orientation: "horizontal" }); - const fieldState = useDatePickerFieldState({ + const segmentState = useSegmentState({ value: dateValue, defaultValue, onChange: setValue, @@ -120,7 +120,7 @@ export const useDatePickerState = (props: DatePickerStateInitialProps = {}) => { isRequired, ...composite, ...popover, - ...fieldState, + ...segmentState, calendar, }; }; diff --git a/src/datepicker/DateSegment.ts b/src/datepicker/DateSegment.ts index 7da764e89..7b360425d 100644 --- a/src/datepicker/DateSegment.ts +++ b/src/datepicker/DateSegment.ts @@ -1,52 +1,12 @@ -import { useState } from "react"; -import { DOMProps } from "@react-types/shared"; -import { mergeProps } from "@react-aria/utils"; -import { callAllHandlers } from "@chakra-ui/utils"; -import { useDateFormatter } from "@react-aria/i18n"; +import { DatePickerStateReturn } from "."; import { createComponent, createHook } from "reakit-system"; -import { - BoxHTMLProps, - useCompositeItem, - CompositeItemOptions, - CompositeItemHTMLProps, - unstable_useId as useId, -} from "reakit"; import { DATE_SEGMENT_KEYS } from "./__keys"; -import { isNumeric, parseNumber } from "./__utils"; -import { useSpinButton } from "../utils/useSpinButton"; -import { DatePickerStateReturn } from "./DatePickerState"; -import { - DatePickerFieldStateReturn, - IDateSegment, -} from "./DatePickerFieldState"; +import { SegmentHTMLProps, useSegment } from "../segment-spinner/Segment"; -export type DateSegmentOptions = CompositeItemOptions & - Pick< - DatePickerFieldStateReturn, - | "fieldValue" - | "increment" - | "incrementPage" - | "setSegment" - | "decrement" - | "decrementPage" - | "confirmPlaceholder" - > & - Pick< - DatePickerStateReturn, - | "pickerId" - | "next" - | "dateFormatter" - | "isDisabled" - | "isRequired" - | "isReadOnly" - > & { - segment: IDateSegment; - }; +export type DateSegmentOptions = DatePickerStateReturn; -export type DateSegmentHTMLProps = CompositeItemHTMLProps & - BoxHTMLProps & - DOMProps; +export type DateSegmentHTMLProps = SegmentHTMLProps; export type DateSegmentProps = DateSegmentOptions & DateSegmentHTMLProps; @@ -55,210 +15,8 @@ export const useDateSegment = createHook< DateSegmentHTMLProps >({ name: "DateSegment", - compose: [useCompositeItem], + compose: useSegment, keys: DATE_SEGMENT_KEYS, - - useOptions(options, htmlProps) { - return { - disabled: - options.isDisabled || - options.isReadOnly || - options.segment.type === "literal", - ...options, - }; - }, - - useProps( - { segment, next, ...options }, - { - onKeyDown: htmlOnKeyDown, - onFocus: htmlOnFocus, - onMouseDown: htmlOnMouseDown, - ...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 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 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 onFocus = () => { - setEnteredKeys(""); - }; - - const onMouseDown = (e: React.MouseEvent) => { - e.stopPropagation(); - }; - - const { id } = useId({ baseId: "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.pickerId} ${id}`, - onKeyDown: callAllHandlers(htmlOnKeyDown, onKeyDown), - onFocus: callAllHandlers(htmlOnFocus, onFocus), - onMouseDown: callAllHandlers(htmlOnMouseDown, onMouseDown), - children: segment.text, - ...htmlProps, - }); - } - }, - - 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, - }; - }, }); export const DateSegment = createComponent({ diff --git a/src/datepicker/DateSegmentField.ts b/src/datepicker/DateSegmentField.ts index 71dacb263..7699aa0e1 100644 --- a/src/datepicker/DateSegmentField.ts +++ b/src/datepicker/DateSegmentField.ts @@ -1,13 +1,15 @@ +import { DatePickerStateReturn } from "."; import { createComponent, createHook } from "reakit-system"; -import { CompositeHTMLProps, CompositeOptions, useComposite } from "reakit"; +import { + SegmentFieldHTMLProps, + useSegmentField, +} from "../segment-spinner/SegmentField"; import { DATE_SEGMENT_FIELD_KEYS } from "./__keys"; -import { DatePickerStateReturn } from "./DatePickerState"; -export type DateSegmentFieldOptions = CompositeOptions & - Pick; +export type DateSegmentFieldOptions = DatePickerStateReturn; -export type DateSegmentFieldHTMLProps = CompositeHTMLProps; +export type DateSegmentFieldHTMLProps = SegmentFieldHTMLProps; export type DateSegmentFieldProps = DateSegmentFieldOptions & DateSegmentFieldHTMLProps; @@ -17,15 +19,8 @@ export const useDateSegmentField = createHook< DateSegmentFieldHTMLProps >({ name: "DateSegmentField", - compose: useComposite, + compose: useSegmentField, keys: DATE_SEGMENT_FIELD_KEYS, - - useProps(options, { onKeyDown: htmlOnKeyDown, ...htmlProps }) { - return { - "aria-labelledby": options.pickerId, - ...htmlProps, - }; - }, }); export const DateSegmentField = createComponent({ diff --git a/src/datepicker/__keys.ts b/src/datepicker/__keys.ts index 2d6be80fc..7db11d422 100644 --- a/src/datepicker/__keys.ts +++ b/src/datepicker/__keys.ts @@ -1,7 +1,8 @@ // Automatically generated -const DATE_PICKER_FIELD_STATE_KEYS = [ - "fieldValue", - "setFieldValue", +const DATE_PICKER_STATE_KEYS = [ + "calendar", + "value", + "setValue", "segments", "dateFormatter", "increment", @@ -10,34 +11,9 @@ const DATE_PICKER_FIELD_STATE_KEYS = [ "decrementPage", "setSegment", "confirmPlaceholder", -] as const; -const DATE_PICKER_STATE_KEYS = [ - ...DATE_PICKER_FIELD_STATE_KEYS, - "calendar", "baseId", "unstable_idCountRef", - "visible", - "animated", - "animating", "setBaseId", - "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", "unstable_virtual", "rtl", "orientation", @@ -69,6 +45,27 @@ const DATE_PICKER_STATE_KEYS = [ "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", @@ -84,8 +81,5 @@ const DATE_PICKER_STATE_KEYS = [ 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, - "segment", -] as const; -export const DATE_SEGMENT_FIELD_KEYS = DATE_PICKER_TRIGGER_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 index 8a84bed27..9c2fcda8d 100644 --- a/src/datepicker/__utils.ts +++ b/src/datepicker/__utils.ts @@ -1,238 +1,4 @@ import { DateValue } from "./index.d"; -import { - getDate, - getDaysInMonth, - getHours, - getMinutes, - getMonth, - getSeconds, - getYear, - setDate, - setHours, - setMinutes, - setMonth, - setSeconds, - setYear, -} from "date-fns"; - -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); - } - default: { - return value; - } - } -} - -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); -} export function setTime(date: Date, time: Date) { if (!date || !time) { diff --git a/src/datepicker/index.ts b/src/datepicker/index.ts index 4fc0af391..ced2f6eed 100644 --- a/src/datepicker/index.ts +++ b/src/datepicker/index.ts @@ -1,7 +1,6 @@ -export * from "./DatePickerState"; -export * from "./DatePickerFieldState"; export * from "./DatePicker"; -export * from "./DatePickerTrigger"; -export * from "./DatePickerContent"; export * from "./DateSegment"; +export * from "./DatePickerState"; export * from "./DateSegmentField"; +export * from "./DatePickerTrigger"; +export * from "./DatePickerContent"; diff --git a/src/segment-spinner/Segment.ts b/src/segment-spinner/Segment.ts index 36e546c25..f351de1b3 100644 --- a/src/segment-spinner/Segment.ts +++ b/src/segment-spinner/Segment.ts @@ -4,29 +4,30 @@ import { CompositeItemHTMLProps, } from "reakit"; import { MouseEvent, useState } from "react"; -import { mergeProps, useId } from "@react-aria/utils"; +import { mergeProps } from "@react-aria/utils"; import { DOMProps } from "@react-types/shared"; +import { unstable_useId as useId } from "reakit"; 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 { DATE_SEGMENT_KEYS } from "../datepicker/__keys"; import { IDateSegment, SegmentStateReturn } from "./SegmentState"; export type SegmentOptions = CompositeItemOptions & Pick< SegmentStateReturn, + | "value" | "next" - | "dateFormatter" - | "confirmPlaceholder" + | "setSegment" | "increment" | "decrement" | "incrementPage" | "decrementPage" - | "setSegment" - | "value" + | "dateFormatter" + | "confirmPlaceholder" > & { segment: IDateSegment; isDisabled?: boolean; @@ -41,11 +42,14 @@ export type SegmentProps = SegmentOptions & SegmentHTMLProps; export const useSegment = createHook({ name: "Segment", compose: useCompositeItem, - keys: DATE_SEGMENT_KEYS, + keys: SEGMENT_KEYS, useOptions(options, htmlProps) { return { - disabled: options.segment.type === "literal", + disabled: + options.isDisabled || + options.isReadOnly || + options.segment.type === "literal", ...options, }; }, @@ -202,7 +206,7 @@ export const useSegment = createHook({ const onMouseDown = (e: MouseEvent) => e.stopPropagation(); - const id = useId(htmlProps.id); + const { id } = useId({ baseId: "segment-spin-button" }); switch (segment.type) { // A separator, e.g. punctuation diff --git a/src/segment-spinner/SegmentField.ts b/src/segment-spinner/SegmentField.ts index af4200313..e9d6034be 100644 --- a/src/segment-spinner/SegmentField.ts +++ b/src/segment-spinner/SegmentField.ts @@ -2,9 +2,8 @@ import { createComponent, createHook } from "reakit-system"; import { CompositeHTMLProps, CompositeOptions, useComposite } from "reakit"; import { SEGMENT_FIELD_KEYS } from "./__keys"; -import { SegmentStateReturn } from "./SegmentState"; -export type SegmentFieldOptions = CompositeOptions & SegmentStateReturn; +export type SegmentFieldOptions = CompositeOptions; export type SegmentFieldHTMLProps = CompositeHTMLProps; From 697b3e9f95a1be3f00a0c7a18ad12999a84a2154 Mon Sep 17 00:00:00 2001 From: Anurag Date: Wed, 7 Oct 2020 14:19:29 +0530 Subject: [PATCH 19/23] fix: datepicker propagation --- src/datepicker/DatePickerContent.ts | 5 --- src/datepicker/stories/DatePicker.stories.tsx | 36 ++++++++++--------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/datepicker/DatePickerContent.ts b/src/datepicker/DatePickerContent.ts index 65ca9ff98..0f1414d65 100644 --- a/src/datepicker/DatePickerContent.ts +++ b/src/datepicker/DatePickerContent.ts @@ -22,13 +22,8 @@ export const useDatePickerContent = createHook< keys: DATE_PICKER_CONTENT_KEYS, useProps({ dialogId }, { onMouseDown: htmlOnMouseDown, ...htmlProps }) { - const onMouseDown = (e: React.MouseEvent) => { - e.stopPropagation(); - }; - return { id: dialogId, - onMouseDown: callAllHandlers(htmlOnMouseDown, onMouseDown), ...htmlProps, }; }, diff --git a/src/datepicker/stories/DatePicker.stories.tsx b/src/datepicker/stories/DatePicker.stories.tsx index 644dff29c..c025b3114 100644 --- a/src/datepicker/stories/DatePicker.stories.tsx +++ b/src/datepicker/stories/DatePicker.stories.tsx @@ -25,27 +25,29 @@ const DatePickerComp: React.FC = props => { }); return ( - -
- - {state.segments.map((segment, i) => ( - - ))} - + <> + +
+ + {state.segments.map((segment, i) => ( + + ))} + - - - -
+ + + +
+
-
+ ); }; From 1ddad5dc4442b8d7e41d9d070125a2aeaae2d4e9 Mon Sep 17 00:00:00 2001 From: Anurag Date: Wed, 7 Oct 2020 14:35:03 +0530 Subject: [PATCH 20/23] refactor: unified DateValue type import & removed extra composite in DatePickerState --- src/calendar/CalendarState.ts | 2 -- src/calendar/__utils.ts | 5 +++-- src/calendar/stories/Calendar.stories.tsx | 2 +- src/datepicker/DatePicker.ts | 2 +- src/datepicker/DatePickerState.ts | 12 +++--------- src/datepicker/__utils.ts | 2 +- src/datepicker/index.d.ts | 1 - src/datepicker/stories/DatePicker.stories.tsx | 3 ++- src/segment-spinner/SegmentState.ts | 4 ++-- src/segment-spinner/__utils.ts | 3 +-- 10 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/calendar/CalendarState.ts b/src/calendar/CalendarState.ts index a1eb595c8..7547fc78a 100644 --- a/src/calendar/CalendarState.ts +++ b/src/calendar/CalendarState.ts @@ -31,8 +31,6 @@ import { useWeekStart } from "./useWeekStart"; import { announce } from "../utils/LiveAnnouncer"; import { isInvalid, useWeekDays, generateDaysInMonthArray } from "./__utils"; -export type DateValue = string | number | Date; - export interface CalendarStateInitialProps extends Partial { id?: string; } diff --git a/src/calendar/__utils.ts b/src/calendar/__utils.ts index 41604f098..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, diff --git a/src/calendar/stories/Calendar.stories.tsx b/src/calendar/stories/Calendar.stories.tsx index c2cc08d53..b2c8bdde5 100644 --- a/src/calendar/stories/Calendar.stories.tsx +++ b/src/calendar/stories/Calendar.stories.tsx @@ -3,7 +3,7 @@ import { Meta } from "@storybook/react"; import { addDays, addWeeks, subWeeks } from "date-fns"; import "./index.css"; -import { DateValue } from "../index"; +import { DateValue } from "../index.d"; import { CalendarComponent } from "./CalendarComponent"; export default { diff --git a/src/datepicker/DatePicker.ts b/src/datepicker/DatePicker.ts index e6fd1a2ab..7d50d2edd 100644 --- a/src/datepicker/DatePicker.ts +++ b/src/datepicker/DatePicker.ts @@ -1,7 +1,7 @@ import { createOnKeyDown } from "reakit-utils"; import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; import { createComponent, createHook } from "reakit-system"; -import { ariaAttr, callAllHandlers, dataAttr } from "@chakra-ui/utils"; +import { ariaAttr, callAllHandlers } from "@chakra-ui/utils"; import { DATE_PICKER_KEYS } from "./__keys"; import { DatePickerStateReturn } from "./DatePickerState"; diff --git a/src/datepicker/DatePickerState.ts b/src/datepicker/DatePickerState.ts index fde108830..8414f0536 100644 --- a/src/datepicker/DatePickerState.ts +++ b/src/datepicker/DatePickerState.ts @@ -7,11 +7,7 @@ import * as React from "react"; import { isValid } from "date-fns"; import { useControllableState } from "@chakra-ui/hooks"; -import { - usePopoverState, - useCompositeState, - unstable_useId as useId, -} from "reakit"; +import { usePopoverState, unstable_useId as useId } from "reakit"; import { setTime, isInvalid } from "./__utils"; import { DateValue, useCalendarState } from "../calendar"; @@ -71,7 +67,6 @@ export const useDatePickerState = (props: DatePickerStateInitialProps = {}) => { }; const popover = usePopoverState(props); - const composite = useCompositeState({ orientation: "horizontal" }); const segmentState = useSegmentState({ value: dateValue, defaultValue, @@ -101,10 +96,10 @@ export const useDatePickerState = (props: DatePickerStateInitialProps = {}) => { React.useEffect(() => { if (autoFocus) { - composite.first(); + segmentState.first(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoFocus, composite.first]); + }, [autoFocus, segmentState.first]); return { pickerId, @@ -118,7 +113,6 @@ export const useDatePickerState = (props: DatePickerStateInitialProps = {}) => { isDisabled, isReadOnly, isRequired, - ...composite, ...popover, ...segmentState, calendar, diff --git a/src/datepicker/__utils.ts b/src/datepicker/__utils.ts index 9c2fcda8d..3d8d8b001 100644 --- a/src/datepicker/__utils.ts +++ b/src/datepicker/__utils.ts @@ -1,4 +1,4 @@ -import { DateValue } from "./index.d"; +import { DateValue } from "../calendar/index.d"; export function setTime(date: Date, time: Date) { if (!date || !time) { diff --git a/src/datepicker/index.d.ts b/src/datepicker/index.d.ts index add6c678a..00373e6fa 100644 --- a/src/datepicker/index.d.ts +++ b/src/datepicker/index.d.ts @@ -6,7 +6,6 @@ import { ValueBase, } from "@react-types/shared"; -export type DateValue = string | number | Date; interface DatePickerBase extends InputBase, Validation, FocusableProps { minValue?: DateValue; maxValue?: DateValue; diff --git a/src/datepicker/stories/DatePicker.stories.tsx b/src/datepicker/stories/DatePicker.stories.tsx index c025b3114..6d4c1fb66 100644 --- a/src/datepicker/stories/DatePicker.stories.tsx +++ b/src/datepicker/stories/DatePicker.stories.tsx @@ -3,7 +3,8 @@ import { Meta } from "@storybook/react"; import { addDays, addWeeks, subWeeks } from "date-fns"; import "./index.css"; -import { DatePickerStateInitialProps, DateValue } from "../index.d"; +import { DateValue } from "../../calendar/index.d"; +import { DatePickerStateInitialProps } from "../index.d"; import { CalendarComp } from "../../calendar/stories/CalendarComponent"; import { DatePicker, diff --git a/src/segment-spinner/SegmentState.ts b/src/segment-spinner/SegmentState.ts index f9ab985cd..f4670dcb6 100644 --- a/src/segment-spinner/SegmentState.ts +++ b/src/segment-spinner/SegmentState.ts @@ -132,8 +132,8 @@ export function useSegmentState(props: SegmentStateProps) { return { ...segmentComposite, - value, - setValue, + fieldValue: value, + setFieldValue: setValue, segments, dateFormatter, increment(part: Intl.DateTimeFormatPartTypes) { diff --git a/src/segment-spinner/__utils.ts b/src/segment-spinner/__utils.ts index 7e8ee8064..d2ddc0736 100644 --- a/src/segment-spinner/__utils.ts +++ b/src/segment-spinner/__utils.ts @@ -13,6 +13,7 @@ import { setSeconds, setYear, } from "date-fns"; +import { DateValue } from "../calendar/index.d"; export function convertValue(value: DateValue | undefined): Date | undefined { if (!value) { @@ -229,5 +230,3 @@ export function parseNumber(str: string): number { export function isNumeric(str: string) { return /^[0-9\u0660-\u0669\u06f0-\u06f9]+$/.test(str); } - -export type DateValue = string | number | Date; From 56acb62f734733aec7c23418f4060f16ef118b59 Mon Sep 17 00:00:00 2001 From: Anurag Date: Wed, 7 Oct 2020 15:11:14 +0530 Subject: [PATCH 21/23] chore: fieldvalue fix --- src/segment-spinner/Segment.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/segment-spinner/Segment.ts b/src/segment-spinner/Segment.ts index f351de1b3..1c115afee 100644 --- a/src/segment-spinner/Segment.ts +++ b/src/segment-spinner/Segment.ts @@ -2,11 +2,11 @@ import { useCompositeItem, CompositeItemOptions, CompositeItemHTMLProps, + unstable_useId as useId, } from "reakit"; import { MouseEvent, useState } from "react"; import { mergeProps } from "@react-aria/utils"; import { DOMProps } from "@react-types/shared"; -import { unstable_useId as useId } from "reakit"; import { callAllHandlers } from "@chakra-ui/utils"; import { useDateFormatter } from "@react-aria/i18n"; import { createComponent, createHook } from "reakit-system"; @@ -19,8 +19,8 @@ import { IDateSegment, SegmentStateReturn } from "./SegmentState"; export type SegmentOptions = CompositeItemOptions & Pick< SegmentStateReturn, - | "value" | "next" + | "fieldValue" | "setSegment" | "increment" | "decrement" @@ -89,9 +89,9 @@ export const useSegment = createHook({ }); if (segment.type === "month") { - textValue = monthDateFormatter.format(options.value); + textValue = monthDateFormatter.format(options.fieldValue); } else if (segment.type === "hour" || segment.type === "dayPeriod") { - textValue = hourDateFormatter.format(options.value); + textValue = hourDateFormatter.format(options.fieldValue); } const { spinButtonProps } = useSpinButton({ From 57fdbe34aabb28ba55c911b3db625d305f1a6793 Mon Sep 17 00:00:00 2001 From: Anurag Date: Wed, 7 Oct 2020 15:16:39 +0530 Subject: [PATCH 22/23] chore: removed DOMProps --- src/datepicker/DateSegment.ts | 8 ++++++-- src/segment-spinner/Segment.ts | 3 +-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/datepicker/DateSegment.ts b/src/datepicker/DateSegment.ts index 7b360425d..b58b0901a 100644 --- a/src/datepicker/DateSegment.ts +++ b/src/datepicker/DateSegment.ts @@ -1,10 +1,14 @@ import { DatePickerStateReturn } from "."; import { createComponent, createHook } from "reakit-system"; +import { + useSegment, + SegmentOptions, + SegmentHTMLProps, +} from "../segment-spinner/Segment"; import { DATE_SEGMENT_KEYS } from "./__keys"; -import { SegmentHTMLProps, useSegment } from "../segment-spinner/Segment"; -export type DateSegmentOptions = DatePickerStateReturn; +export type DateSegmentOptions = SegmentOptions & DatePickerStateReturn; export type DateSegmentHTMLProps = SegmentHTMLProps; diff --git a/src/segment-spinner/Segment.ts b/src/segment-spinner/Segment.ts index 1c115afee..537a48ddf 100644 --- a/src/segment-spinner/Segment.ts +++ b/src/segment-spinner/Segment.ts @@ -6,7 +6,6 @@ import { } from "reakit"; import { MouseEvent, useState } from "react"; import { mergeProps } from "@react-aria/utils"; -import { DOMProps } from "@react-types/shared"; import { callAllHandlers } from "@chakra-ui/utils"; import { useDateFormatter } from "@react-aria/i18n"; import { createComponent, createHook } from "reakit-system"; @@ -35,7 +34,7 @@ export type SegmentOptions = CompositeItemOptions & isRequired?: boolean; }; -export type SegmentHTMLProps = CompositeItemHTMLProps & DOMProps; +export type SegmentHTMLProps = CompositeItemHTMLProps; export type SegmentProps = SegmentOptions & SegmentHTMLProps; From b02635f64fc66eec05571883e535d22123512590 Mon Sep 17 00:00:00 2001 From: Anurag Date: Wed, 7 Oct 2020 15:40:36 +0530 Subject: [PATCH 23/23] test: css ignore tests --- jest.config.js | 3 +++ src/__mocks__/styleMock.js | 1 + src/utils/useSpinButton.ts | 2 ++ 3 files changed, 6 insertions(+) create mode 100644 src/__mocks__/styleMock.js 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/utils/useSpinButton.ts b/src/utils/useSpinButton.ts index a48c62879..98ce45eb1 100644 --- a/src/utils/useSpinButton.ts +++ b/src/utils/useSpinButton.ts @@ -71,6 +71,7 @@ export function useSpinButton(props: SpinButtonProps): SpinbuttonAria { } switch (e.key) { + // @ts-expect-error case "PageUp": if (onIncrementPage) { e.preventDefault(); @@ -85,6 +86,7 @@ export function useSpinButton(props: SpinButtonProps): SpinbuttonAria { onIncrement(); } break; + // @ts-expect-error case "PageDown": if (onDecrementPage) { e.preventDefault();