diff --git a/package.json b/package.json index 1896b3d55..79246e962 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@react-aria/interactions": "3.2.0", "@react-aria/link": "3.1.1", "@react-aria/utils": "3.2.1", + "@react-aria/visually-hidden": "^3.2.0", "@react-stately/toggle": "3.2.0", "date-fns": "^2.16.1", "react-use-gesture": "7.0.16", diff --git a/src/calendar-v1/CalendarCell.ts b/src/calendar-v1/CalendarCell.ts deleted file mode 100644 index 4760382be..000000000 --- a/src/calendar-v1/CalendarCell.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Aria [useCalendarBase](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useCalendarBase.ts) - * to work with Reakit System - */ -import { ariaAttr } from "@chakra-ui/utils"; -import { isSameDay, isWeekend } from "date-fns"; -import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; -import { createComponent, createHook } from "reakit-system"; - -import { CALENDAR_CELL_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; - -export type CalendarCellOptions = BoxOptions & - Pick & { - date: Date; - }; - -export type CalendarCellHTMLProps = BoxHTMLProps; - -export type CalendarCellProps = CalendarCellOptions & CalendarCellHTMLProps; - -export const useCalendarCell = createHook< - CalendarCellOptions, - CalendarCellHTMLProps ->({ - name: "CalendarCell", - compose: useBox, - keys: CALENDAR_CELL_KEYS, - - useProps({ date, dateValue }, htmlProps) { - const isSelected = dateValue ? isSameDay(date, dateValue) : false; - - return { - role: "gridcell", - "data-weekend": isWeekend(date), - "aria-selected": ariaAttr(isSelected), - ...htmlProps, - }; - }, -}); - -export const CalendarCell = createComponent({ - as: "div", - memo: true, - useHook: useCalendarCell, -}); diff --git a/src/calendar-v1/__utils.ts b/src/calendar-v1/__utils.ts deleted file mode 100644 index 915f99436..000000000 --- a/src/calendar-v1/__utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * for these utils inspiration - */ -import { setDay } from "date-fns"; -import { useDateFormatter } from "@react-aria/i18n"; - -export function isInvalid( - date: Date, - minDate: Date | null, - maxDate: Date | null, -) { - return ( - (minDate != null && date < minDate) || (maxDate != null && date > maxDate) - ); -} - -export function useWeekDays(weekStart: number) { - const dayFormatter = useDateFormatter({ weekday: "short" }); - const dayFormatterLong = useDateFormatter({ weekday: "long" }); - - return [...new Array(7).keys()].map(index => { - const dateDay = setDay(Date.now(), (index + weekStart) % 7); - const day = dayFormatter.format(dateDay); - const dayLong = dayFormatterLong.format(dateDay); - return { title: dayLong, abbr: day }; - }); -} diff --git a/src/calendar-v1/Calendar.ts b/src/calendar/Calendar.ts similarity index 100% rename from src/calendar-v1/Calendar.ts rename to src/calendar/Calendar.ts diff --git a/src/calendar-v1/CalendarButton.ts b/src/calendar/CalendarButton.ts similarity index 100% rename from src/calendar-v1/CalendarButton.ts rename to src/calendar/CalendarButton.ts diff --git a/src/calendar/CalendarCell.ts b/src/calendar/CalendarCell.ts new file mode 100644 index 000000000..c843386a4 --- /dev/null +++ b/src/calendar/CalendarCell.ts @@ -0,0 +1,96 @@ +/** + * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) + * We improved the Calendar from Aria [useCalendarBase](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useCalendarBase.ts) + * to work with Reakit System + */ +import { useCallback } from "react"; +import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; +import { createComponent, createHook } from "reakit-system"; +import { getDaysInMonth, isSameDay, isWeekend } from "date-fns"; +import { ariaAttr, callAllHandlers, dataAttr } from "@chakra-ui/utils"; + +import { CALENDAR_CELL_KEYS } from "./__keys"; +import { CalendarStateReturn } from "./CalendarState"; +import { RangeCalendarStateReturn } from "./RangeCalendarState"; + +export type CalendarCellOptions = BoxOptions & + Pick & + Partial< + Pick + > & { + date: Date; + }; + +export type CalendarCellHTMLProps = BoxHTMLProps; + +export type CalendarCellProps = CalendarCellOptions & CalendarCellHTMLProps; + +export const useCalendarCell = createHook< + CalendarCellOptions, + CalendarCellHTMLProps +>({ + name: "CalendarCell", + compose: useBox, + keys: CALENDAR_CELL_KEYS, + + useProps(options, { onMouseEnter: htmlOnMouseEnter, ...htmlProps }) { + const { isDisabled, highlightDate, date } = options; + const onMouseEnter = useCallback(() => { + if (isDisabled) return; + + highlightDate?.(date); + }, [date, highlightDate, isDisabled]); + + return { + role: "gridcell", + "data-weekend": dataAttr(isWeekend(date)), + onMouseEnter: + "highlightDate" in options + ? callAllHandlers(htmlOnMouseEnter, onMouseEnter) + : htmlOnMouseEnter, + ...getCalendarCellProps(options), + ...htmlProps, + }; + }, +}); + +export const CalendarCell = createComponent({ + as: "div", + memo: true, + useHook: useCalendarCell, +}); + +const getCalendarCellProps = (options: CalendarCellOptions) => { + const { date, dateValue, highlightedRange, currentMonth } = options; + + if ("highlightDate" in options) { + const isSelected = highlightedRange + ? date >= highlightedRange.start && date <= highlightedRange.end + : false; + + const isRangeStart = isSelected && date.getDate() === 1; + const isRangeEnd = + isSelected && date.getDate() === getDaysInMonth(currentMonth); + const isSelectionStart = highlightedRange + ? isSameDay(date, highlightedRange.start) + : false; + const isSelectionEnd = highlightedRange + ? isSameDay(date, highlightedRange.end) + : false; + + return { + "aria-selected": ariaAttr(isSelected), + "data-is-range-selection": dataAttr(isSelected), + "data-is-range-end": dataAttr(isRangeEnd), + "data-is-range-start": dataAttr(isRangeStart), + "data-is-selection-end": dataAttr(isSelectionEnd), + "data-is-selection-start": dataAttr(isSelectionStart), + }; + } + + const isSelected = dateValue ? isSameDay(date, dateValue) : false; + + return { + "aria-selected": ariaAttr(isSelected), + }; +}; diff --git a/src/calendar-v1/CalendarCellButton.ts b/src/calendar/CalendarCellButton.ts similarity index 82% rename from src/calendar-v1/CalendarCellButton.ts rename to src/calendar/CalendarCellButton.ts index 91b272599..10cae220e 100644 --- a/src/calendar-v1/CalendarCellButton.ts +++ b/src/calendar/CalendarCellButton.ts @@ -14,6 +14,7 @@ import { ButtonHTMLProps, ButtonOptions, useButton } from "reakit"; import { isInvalid } from "./__utils"; import { CALENDAR_CELL_BUTTON_KEYS } from "./__keys"; import { CalendarStateReturn } from "./CalendarState"; +import { RangeCalendarStateReturn } from "./RangeCalendarState"; export type CalendarCellButtonOptions = ButtonOptions & Pick< @@ -27,7 +28,8 @@ export type CalendarCellButtonOptions = ButtonOptions & | "maxDate" | "dateValue" | "isFocused" - > & { + > & + Partial> & { date: Date; }; @@ -69,7 +71,9 @@ export const useCalendarCellButton = createHook< disabled, dateValue, selectDate, + anchorDate, focusedDate, + isDisabled, setFocusedDate, isFocused: isFocusedOption, } = options; @@ -124,6 +128,25 @@ export const useCalendarCellButton = createHook< ariaLabel = `${ariaLabel} selected`; } + // When a cell is focused and this is a range calendar, add a prompt to help + // screenreader users know that they are in a range selection mode. + if (anchorDate && isFocused && !isDisabled) { + let rangeSelectionPrompt = ""; + + // If selection has started add "click to finish selecting range" + if (anchorDate) { + rangeSelectionPrompt = "click to finish selecting range"; + // Otherwise, add "click to start selecting range" prompt + } else { + rangeSelectionPrompt = "click to start selecting range"; + } + + // Append to aria-label + if (rangeSelectionPrompt) { + ariaLabel = `${ariaLabel} (${rangeSelectionPrompt})`; + } + } + return { children: useDateFormatter({ day: "numeric" }).format(date), tabIndex, diff --git a/src/calendar-v1/CalendarGrid.ts b/src/calendar/CalendarGrid.ts similarity index 80% rename from src/calendar-v1/CalendarGrid.ts rename to src/calendar/CalendarGrid.ts index b5cb2d296..3f82ecd6d 100644 --- a/src/calendar-v1/CalendarGrid.ts +++ b/src/calendar/CalendarGrid.ts @@ -3,14 +3,16 @@ * We improved the Calendar from Stately [useWeekStart](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/calendar/src/useWeekStart.ts) * to work with Reakit System */ +import { chain } from "@react-aria/utils"; +import { createOnKeyDown, useForkRef } from "reakit-utils"; import { KeyboardEvent, useRef } from "react"; import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; -import { createOnKeyDown, useForkRef } from "reakit-utils"; import { createComponent, createHook } from "reakit-system"; import { ariaAttr, callAllHandlers } from "@chakra-ui/utils"; import { CALENDAR_GRID_KEYS } from "./__keys"; import { CalendarStateReturn } from "./CalendarState"; +import { RangeCalendarStateReturn } from "./RangeCalendarState"; export type CalendarGridOptions = BoxOptions & Pick< @@ -30,7 +32,8 @@ export type CalendarGridOptions = BoxOptions & | "focusPreviousDay" | "focusNextWeek" | "focusPreviousWeek" - >; + > & + Partial>; export type CalendarGridHTMLProps = BoxHTMLProps; @@ -70,6 +73,7 @@ export const useCalendarGrid = createHook< focusNextWeek, focusPreviousWeek, calendarId, + setAnchorDate, } = options; const ref = useRef(null); @@ -98,15 +102,37 @@ export const useCalendarGrid = createHook< }, }); + let rangeCalendarProps = {}; + + if ("highlightDate" in options) { + const onRangeKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case "Escape": + // Cancel the selection. + setAnchorDate?.(null); + break; + } + }; + + rangeCalendarProps = { + "aria-multiselectable": true, + onKeyDown: callAllHandlers( + htmlOnKeyDown, + chain(onKeyDown, onRangeKeyDown), + ), + }; + } + return { ref: useForkRef(ref, htmlRef), role: "grid", "aria-labelledby": calendarId, "aria-readonly": ariaAttr(isReadOnly), "aria-disabled": ariaAttr(isDisabled), - onKeyDown, + onKeyDown: callAllHandlers(htmlOnKeyDown, onKeyDown), onFocus: callAllHandlers(htmlOnFocus, () => setFocused(true)), onBlur: callAllHandlers(htmlOnBlur, () => setFocused(false)), + ...rangeCalendarProps, ...htmlProps, }; }, diff --git a/src/calendar-v1/CalendarHeader.ts b/src/calendar/CalendarHeader.ts similarity index 100% rename from src/calendar-v1/CalendarHeader.ts rename to src/calendar/CalendarHeader.ts diff --git a/src/calendar-v1/CalendarState.ts b/src/calendar/CalendarState.ts similarity index 72% rename from src/calendar-v1/CalendarState.ts rename to src/calendar/CalendarState.ts index 988377ed6..f790e8745 100644 --- a/src/calendar-v1/CalendarState.ts +++ b/src/calendar/CalendarState.ts @@ -3,8 +3,10 @@ * 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 { useState } from "react"; +import * as React from "react"; import { unstable_useId as useId } from "reakit"; +import { useUpdateEffect } from "@chakra-ui/hooks"; +import { useDateFormatter } from "@react-aria/i18n"; import { useControllableState } from "@chakra-ui/hooks"; import { addDays, @@ -13,6 +15,7 @@ import { addYears, endOfDay, endOfMonth, + format, getDaysInMonth, isSameMonth, startOfDay, @@ -23,22 +26,12 @@ import { subYears, } from "date-fns"; +import { CalendarProps } from "./index.d"; import { useWeekStart } from "./useWeekStart"; -import { isInvalid, useWeekDays } from "./__utils"; - -export type DateValue = string | number | Date; -export interface IUseCalendarProps { - 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; +import { announce } from "../utils/LiveAnnouncer"; +import { generateDaysInMonthArray, isInvalid, useWeekDays } from "./__utils"; + +export interface IUseCalendarProps extends CalendarProps { id?: string; } @@ -69,11 +62,11 @@ export function useCalendarState(props: IUseCalendarProps = {}) { const minDate = minValue ? startOfDay(minValue) : null; const maxDate = maxValue ? endOfDay(maxValue) : null; - const [isFocused, setFocused] = useState(autoFocus); + const [isFocused, setFocused] = React.useState(autoFocus); const initialMonth = dateValue ?? new Date(); - const [currentMonth, setCurrentMonth] = useState(initialMonth); // TODO: does this need to be in state at all?? - const [focusedDate, setFocusedDate] = useState(initialMonth); + const [currentMonth, setCurrentMonth] = React.useState(initialMonth); // TODO: does this need to be in state at all?? + const [focusedDate, setFocusedDate] = React.useState(initialMonth); const month = currentMonth.getMonth(); const year = currentMonth.getFullYear(); @@ -84,25 +77,14 @@ export function useCalendarState(props: IUseCalendarProps = {}) { if (monthStartsAt < 0) { monthStartsAt += 7; } + const days = getDaysInMonth(currentMonth); const weeksInMonth = Math.ceil((monthStartsAt + days) / 7); // Get 2D Date arrays in 7 days a week format - const daysInMonth = [...new Array(weeksInMonth).keys()].reduce( - (weeks: Date[][], weekIndex) => { - const daysInWeek = [...new Array(7).keys()].reduce( - (days: Date[], dayIndex) => { - const day = weekIndex * 7 + dayIndex - monthStartsAt + 1; - const cellDate = new Date(year, month, day); - - return [...days, cellDate]; - }, - [], - ); - - return [...weeks, daysInWeek]; - }, - [], + const daysInMonth = React.useMemo( + () => generateDaysInMonthArray(month, monthStartsAt, weeksInMonth, year), + [month, monthStartsAt, weeksInMonth, year], ); // Sets focus to a specific cell date @@ -124,6 +106,24 @@ export function useCalendarState(props: IUseCalendarProps = {}) { } } + const monthFormatter = useDateFormatter({ month: "long", year: "numeric" }); + + // Announce when the current month changes + useUpdateEffect(() => { + // announce the new month with a change from the Previous or Next button + if (!isFocused) { + announce(monthFormatter.format(currentMonth)); + } + // handle an update to the current month from the Previous or Next button + // rather than move focus, we announce the new month value + }, [currentMonth]); + + useUpdateEffect(() => { + if (!dateValue) return; + + announce(`Selected Date: ${format(dateValue, "do MMM yyyy")}`); + }, [dateValue]); + return { calendarId, dateValue, diff --git a/src/calendar-v1/CalendarWeekTitle.ts b/src/calendar/CalendarWeekTitle.ts similarity index 100% rename from src/calendar-v1/CalendarWeekTitle.ts rename to src/calendar/CalendarWeekTitle.ts diff --git a/src/calendar/RangeCalendarState.ts b/src/calendar/RangeCalendarState.ts new file mode 100644 index 000000000..70aad7d00 --- /dev/null +++ b/src/calendar/RangeCalendarState.ts @@ -0,0 +1,93 @@ +/** + * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) + * We improved the Calendar from Stately [useRangeCalendar](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useRangeCalendar.ts) + * to work with Reakit System + */ +import * as React from "react"; +import { format, isSameDay } from "date-fns"; +import { RangeCalendarProps } from "./index.d"; +import { useCalendarState } from "./CalendarState"; +import { useControllableState, useUpdateEffect } from "@chakra-ui/hooks"; + +import { announce } from "../utils/LiveAnnouncer"; +import { convertRange, makeRange } from "./__utils"; + +export function useRangeCalendarState(props: RangeCalendarProps = {}) { + const { + value: initialValue, + defaultValue, + onChange, + ...calendarProps + } = props; + + const [value, setValue] = useControllableState({ + value: initialValue, + defaultValue, + onChange, + shouldUpdate: (prev, next) => prev !== next, + }); + + const dateRange = value != null ? convertRange(value) : null; + const [anchorDate, setAnchorDate] = React.useState(null); + const calendar = useCalendarState({ + ...calendarProps, + value: value && value.start, + }); + + const highlightedRange = anchorDate + ? makeRange(anchorDate, calendar.focusedDate) + : value && dateRange && makeRange(dateRange.start, dateRange.end); + + const selectDate = (date: Date) => { + if (props.isReadOnly) { + return; + } + + if (!anchorDate) { + setAnchorDate(date); + } else { + setValue(makeRange(anchorDate, date)); + setAnchorDate(null); + } + }; + + useUpdateEffect(() => { + if (anchorDate) return; + if (!highlightedRange) return; + if (isSameDay(highlightedRange.start, highlightedRange.end)) { + announce( + `Selected range, from ${format( + highlightedRange.start, + "do MMM yyyy", + )} to ${format(highlightedRange.start, "do MMM yyyy")}`, + ); + } else { + announce( + `Selected range, from ${format( + highlightedRange.start, + "do MMM yyyy", + )} to ${format(highlightedRange.end, "do MMM yyyy")}`, + ); + } + }, [highlightedRange]); + + return { + ...calendar, + dateRangeValue: dateRange, + setDateRangeValue: setValue, + anchorDate, + setAnchorDate, + highlightedRange, + selectDate, + selectFocusedDate() { + selectDate(calendar.focusedDate); + }, + highlightDate(date: Date) { + if (anchorDate) { + calendar.setFocusedDate(date); + } + }, + }; +} + +export type RangeCalendarStateReturn = ReturnType; diff --git a/src/calendar-v1/__keys.ts b/src/calendar/__keys.ts similarity index 80% rename from src/calendar-v1/__keys.ts rename to src/calendar/__keys.ts index f7dee598e..9ffe76e65 100644 --- a/src/calendar-v1/__keys.ts +++ b/src/calendar/__keys.ts @@ -31,7 +31,16 @@ const CALENDAR_STATE_KEYS = [ "selectFocusedDate", "selectDate", ] as const; -export const CALENDAR_KEYS = CALENDAR_STATE_KEYS; +const RANGE_CALENDAR_STATE_KEYS = [ + ...CALENDAR_STATE_KEYS, + "dateRangeValue", + "setDateRangeValue", + "anchorDate", + "setAnchorDate", + "highlightedRange", + "highlightDate", +] as const; +export const CALENDAR_KEYS = RANGE_CALENDAR_STATE_KEYS; export const CALENDAR_BUTTON_KEYS = [ ...CALENDAR_KEYS, "goto", diff --git a/src/calendar/__utils.ts b/src/calendar/__utils.ts new file mode 100644 index 000000000..1ce39bf91 --- /dev/null +++ b/src/calendar/__utils.ts @@ -0,0 +1,69 @@ +/** + * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) + * for these utils inspiration + */ +import { DateValue } from "./index.d"; +import { RangeValue } from "@react-types/shared"; +import { useDateFormatter } from "@react-aria/i18n"; +import { endOfDay, setDay, startOfDay } from "date-fns"; + +export function isInvalid( + date: Date, + minDate: Date | null, + maxDate: Date | null, +) { + return ( + (minDate != null && date < minDate) || (maxDate != null && date > maxDate) + ); +} + +export function useWeekDays(weekStart: number) { + const dayFormatter = useDateFormatter({ weekday: "short" }); + const dayFormatterLong = useDateFormatter({ weekday: "long" }); + + return [...new Array(7).keys()].map(index => { + const dateDay = setDay(Date.now(), (index + weekStart) % 7); + const day = dayFormatter.format(dateDay); + const dayLong = dayFormatterLong.format(dateDay); + return { title: dayLong, abbr: day }; + }); +} + +export function generateDaysInMonthArray( + month: number, + monthStartsAt: number, + weeksInMonth: number, + year: number, +) { + return [...new Array(weeksInMonth).keys()].reduce( + (weeks: Date[][], weekIndex) => { + const daysInWeek = [...new Array(7).keys()].reduce( + (days: Date[], dayIndex) => { + const day = weekIndex * 7 + dayIndex - monthStartsAt + 1; + const cellDate = new Date(year, month, day); + + return [...days, cellDate]; + }, + [], + ); + + return [...weeks, daysInWeek]; + }, + [], + ); +} + +export function makeRange(start: Date, end: Date): RangeValue { + if (end < start) { + [start, end] = [end, start]; + } + + return { start: startOfDay(start), end: endOfDay(end) }; +} + +export function convertRange(range: RangeValue): RangeValue { + return { + start: new Date(range.start), + end: new Date(range.end), + }; +} diff --git a/src/calendar/index.d.ts b/src/calendar/index.d.ts new file mode 100644 index 000000000..f70a36a2d --- /dev/null +++ b/src/calendar/index.d.ts @@ -0,0 +1,17 @@ +import { RangeValue, ValueBase } from "@react-types/shared"; + +export type DateValue = string | number | Date; +export interface CalendarPropsBase { + minValue?: DateValue; + maxValue?: DateValue; + isDisabled?: boolean; + isReadOnly?: boolean; + autoFocus?: boolean; +} + +export interface CalendarProps + extends CalendarPropsBase, + ValueBase {} +export interface RangeCalendarProps + extends CalendarPropsBase, + ValueBase> {} diff --git a/src/calendar-v1/index.ts b/src/calendar/index.ts similarity index 77% rename from src/calendar-v1/index.ts rename to src/calendar/index.ts index e6986c55a..49ae6e102 100644 --- a/src/calendar-v1/index.ts +++ b/src/calendar/index.ts @@ -2,7 +2,9 @@ export * from "./Calendar"; export * from "./CalendarCell"; export * from "./CalendarCellButton"; export * from "./CalendarState"; +export * from "./RangeCalendarState"; export * from "./CalendarButton"; export * from "./CalendarGrid"; export * from "./CalendarHeader"; export * from "./CalendarWeekTitle"; +export { DateValue } from "./index.d"; diff --git a/src/calendar-v1/stories/Calendar.stories.tsx b/src/calendar/stories/Calendar.stories.tsx similarity index 94% rename from src/calendar-v1/stories/Calendar.stories.tsx rename to src/calendar/stories/Calendar.stories.tsx index 289d211ab..3628ffdf0 100644 --- a/src/calendar-v1/stories/Calendar.stories.tsx +++ b/src/calendar/stories/Calendar.stories.tsx @@ -22,7 +22,6 @@ export default { const CalendarComp: React.FC = props => { const state = useCalendarState(props); - console.log("%c state", "color: #e5de73", state); return ( @@ -130,7 +129,17 @@ export const DefaultValue = () => ( ); export const ControlledValue = () => { const [value, setValue] = React.useState(addDays(new Date(), 1)); - return ; + + return ( +
+ setValue(new Date(e.target.value))} + value={(value as Date).toISOString().slice(0, 10)} + /> + +
+ ); }; export const MinMaxDate = () => ( diff --git a/src/calendar/stories/RangeCalendar.stories.tsx b/src/calendar/stories/RangeCalendar.stories.tsx new file mode 100644 index 000000000..41e080957 --- /dev/null +++ b/src/calendar/stories/RangeCalendar.stories.tsx @@ -0,0 +1,173 @@ +import * as React from "react"; +import { Meta } from "@storybook/react"; +import { addDays, addWeeks, setDate, subDays, subWeeks } from "date-fns"; + +import "./range-style.css"; +import { DateValue, RangeCalendarProps } from "../index.d"; +import { useRangeCalendarState } from "../RangeCalendarState"; +import { + Calendar, + CalendarCell, + CalendarGrid, + CalendarHeader, + CalendarButton, + CalendarCellButton, + CalendarWeekTitle, +} from "../index"; + +export default { + title: "Component/RangeCalendar", +} as Meta; + +const RangeCalendarComp: React.FC = props => { + const state = useRangeCalendarState(props); + + return ( + console.log("change")} + className="calendar-range" + > +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + {state.weekDays.map((day, dayIndex) => { + return ( + + {day.abbr} + + ); + })} + + + + {state.daysInMonth.map((week, weekIndex) => ( + + {week.map((day, dayIndex) => ( + + + + ))} + + ))} + + +
+ ); +}; + +export const Default = () => ; +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 ( +
+ setStart(new Date(e.target.value))} + value={(start as Date).toISOString().slice(0, 10)} + /> + setEnd(new Date(e.target.value))} + value={(end as Date).toISOString().slice(0, 10)} + /> + { + setStart(start); + setEnd(end); + }} + /> +
+ ); +}; + +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-v1/stories/index.css b/src/calendar/stories/index.css similarity index 96% rename from src/calendar-v1/stories/index.css rename to src/calendar/stories/index.css index 4b9b2afbc..dc29ab141 100644 --- a/src/calendar-v1/stories/index.css +++ b/src/calendar/stories/index.css @@ -1,7 +1,3 @@ -body { - font-family: "Century Gothic"; -} - .calendar { margin-top: 1em; max-width: 320px; @@ -141,6 +137,6 @@ body { .calendar [aria-disabled="true"] { color: #959595; } -.calendar [data-weekend="true"] { +.calendar [data-weekend] { color: #fa3131; } diff --git a/src/calendar/stories/range-style.css b/src/calendar/stories/range-style.css new file mode 100644 index 000000000..e5176ab66 --- /dev/null +++ b/src/calendar/stories/range-style.css @@ -0,0 +1,143 @@ +.calendar-range { + margin-top: 1em; + max-width: 320px; + position: relative; +} + +.calendar-range .header { + cursor: default; + background-color: #fbfbff; + padding: 7px; + font-weight: bold; + text-transform: uppercase; + color: #676d7e; + display: flex; + justify-content: space-around; + align-items: center; +} + +.calendar-range h2 { + margin: 0; + padding: 0; + display: inline-block; + font-size: 1em; + color: #676d7e; + text-transform: none; + font-weight: bold; +} + +.calendar-range button { + border-style: none; + background: transparent; +} + +.calendar-range button::-moz-focus-inner { + border: 0; +} + +.calendar-range .prev-year, +.calendar-range .prev-month, +.calendar-range .next-month, +.calendar-range .next-year { + padding: 4px; + width: 24px; + height: 24px; + color: #676d7e; +} + +.calendar-range .prev-year:focus, +.calendar-range .prev-month:focus, +.calendar-range .next-month:focus, +.calendar-range .next-year:focus { + padding: 2px; + border: 2px solid #676d7e; + border-radius: 4px; + outline: 0; +} + +.calendar-range .prev-year:hover, +.calendar-range .prev-month:hover, +.calendar-range .next-month:hover, +.calendar-range .next-year:hover { + padding: 3px; + border: 1px solid #676d7e; + border-radius: 4px; + outline: 0; +} + +.calendar-range .month-year { + display: inline-block; + width: 12em; + text-align: center; + display: flex; + align-items: center; + justify-content: center; +} + +.calendar-range .dates { + width: 320px; + padding-left: 1em; + padding-right: 1em; + padding-top: 1em; + background-color: #fbfbff; + border-spacing: 0; +} + +.calendar-range .dates th, +.calendar-range .dates td { + text-align: center; +} + +.calendar-range .dates tr { + border: 1px solid black; +} + +.calendar-range .dates td { + height: 40px; + width: 40px; + border-radius: 5px; + font-size: 15px; + padding: 1px 0; +} + +.calendar-range .dates td span { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + user-select: none; +} + +.calendar-range [aria-disabled="true"] { + color: #959595; +} +.calendar-range [data-weekend] { + color: #fa3131; +} + +[data-is-range-start] > span, +[data-is-range-end] > span, +[data-is-range-selection] > span, +[data-is-selection-start] > span, +[data-is-selection-end] > span { + color: white !important; +} + +[data-is-range-start] > span { + background-color: #1141aa; +} +[data-is-range-end] > span { + background-color: #1141aa; +} +[data-is-range-selection] > span { + background-color: #427af3; +} +[data-is-selection-start] > span { + background-color: #1141aa; + border-radius: 50% 0 0 50%; +} +[data-is-selection-end] > span { + background-color: #1141aa; + border-radius: 0% 50% 50% 0%; +} diff --git a/src/calendar-v1/useWeekStart.ts b/src/calendar/useWeekStart.ts similarity index 100% rename from src/calendar-v1/useWeekStart.ts rename to src/calendar/useWeekStart.ts diff --git a/src/utils/LiveAnnouncer.tsx b/src/utils/LiveAnnouncer.tsx new file mode 100644 index 000000000..432cc8ef5 --- /dev/null +++ b/src/utils/LiveAnnouncer.tsx @@ -0,0 +1,163 @@ +/* + * 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 React, { Fragment, useImperativeHandle, useRef, useState } from "react"; +import ReactDOM from "react-dom"; +import { VisuallyHidden } from "@react-aria/visually-hidden"; + +/* Inspired by https://github.com/AlmeroSteyn/react-aria-live */ +const liveRegionAnnouncer = React.createRef(); +let node: any = null; +let clearTimeoutId: any = null; +const LIVEREGION_TIMEOUT_DELAY = 1000; + +type TAriaLive = "assertive" | "off" | "polite" | undefined; + +/** + * Announces the message using screen reader technology. + */ +export function announce( + message: string, + assertiveness = "assertive", + timeout = LIVEREGION_TIMEOUT_DELAY, +) { + ensureInstance(announcer => + announcer.announce(message, assertiveness, timeout), + ); +} + +/** + * Stops all queued announcements. + */ +export function clearAnnouncer(assertiveness: TAriaLive) { + ensureInstance(announcer => announcer.clear(assertiveness)); +} + +/** + * Removes the announcer from the DOM. + */ +export function destroyAnnouncer() { + if (liveRegionAnnouncer.current) { + ReactDOM.unmountComponentAtNode(node); + document.body.removeChild(node); + node = null; + } +} + +/** + * Ensures we only have one instance of the announcer so that we don't have elements competing. + */ +function ensureInstance(callback: (announcer: any) => void) { + if (!liveRegionAnnouncer.current) { + node = document.createElement("div"); + document.body.appendChild(node); + ReactDOM.render( + , + node, + () => callback(liveRegionAnnouncer.current), + ); + } else { + callback(liveRegionAnnouncer.current); + } +} + +const LiveRegionAnnouncer = React.forwardRef((props, ref) => { + const [assertiveMessage, setAssertiveMessage] = useState(""); + const [politeMessage, setPoliteMessage] = useState(""); + + const clear = (assertiveness: TAriaLive) => { + if (!assertiveness || assertiveness === "assertive") { + setAssertiveMessage(""); + } + + if (!assertiveness || assertiveness === "polite") { + setPoliteMessage(""); + } + }; + + const announce = ( + message: string, + assertiveness: TAriaLive = "assertive", + timeout = LIVEREGION_TIMEOUT_DELAY, + ) => { + if (clearTimeoutId) { + clearTimeout(clearTimeoutId); + clearTimeoutId = null; + } + + if (assertiveness === "assertive") { + setAssertiveMessage(message); + } else { + setPoliteMessage(message); + } + + if (message !== "") { + clearTimeoutId = setTimeout(() => { + clear(assertiveness); + }, timeout); + } + }; + + useImperativeHandle(ref, () => ({ + announce, + clear, + })); + + return ( + + + + + ); +}); + +function MessageAlternator({ + message = "", + "aria-live": ariaLive, +}: { + message: string; + "aria-live": TAriaLive; +}) { + const messagesRef = useRef(["", ""]); + const indexRef = useRef(0); + + if (message !== messagesRef.current[indexRef.current]) { + messagesRef.current[indexRef.current] = ""; + indexRef.current = (indexRef.current + 1) % 2; + messagesRef.current[indexRef.current] = message; + } + + return ( + + + + + ); +} + +function MessageBlock({ + message = "", + "aria-live": ariaLive, +}: { + message: string; + "aria-live": TAriaLive; +}) { + return ( + + {message} + + ); +}