diff --git a/packages/bento-design-system/package.json b/packages/bento-design-system/package.json index 95bf18185..087dcf7e0 100644 --- a/packages/bento-design-system/package.json +++ b/packages/bento-design-system/package.json @@ -75,6 +75,7 @@ "@internationalized/date": "3.4.0", "@react-aria/breadcrumbs": "3.5.4", "@react-aria/button": "3.8.1", + "@react-aria/calendar": "3.4.0", "@react-aria/checkbox": "3.10.0", "@react-aria/datepicker": "3.6.0", "@react-aria/dialog": "3.5.4", @@ -96,6 +97,7 @@ "@react-aria/tooltip": "3.6.1", "@react-aria/utils": "3.19.0", "@react-aria/visually-hidden": "3.8.3", + "@react-stately/calendar": "3.3.0", "@react-stately/checkbox": "3.4.4", "@react-stately/datepicker": "3.6.0", "@react-stately/menu": "3.5.4", @@ -105,6 +107,7 @@ "@react-stately/slider": "3.4.1", "@react-stately/toggle": "3.6.1", "@react-stately/tooltip": "3.4.3", + "@react-types/calendar": "3.3.0", "@react-types/overlays": "3.8.1", "@react-types/radio": "3.5.0", "@react-types/shared": "3.19.0", diff --git a/packages/bento-design-system/src/Button/Button.tsx b/packages/bento-design-system/src/Button/Button.tsx index 808003e0f..eec59b144 100644 --- a/packages/bento-design-system/src/Button/Button.tsx +++ b/packages/bento-design-system/src/Button/Button.tsx @@ -6,7 +6,7 @@ import { AriaButtonProps } from "@react-types/button"; import { useButton } from "@react-aria/button"; import { Label } from "../Typography/Label/Label"; import { Column, Columns } from "../Layout/Columns"; -import { IconProps } from ".."; +import { Children, IconProps } from ".."; import { useBentoConfig } from "../BentoConfigContext"; import pick from "lodash.pick"; import { getRadiusPropsFromConfig } from "../util/BorderRadiusConfig"; @@ -31,7 +31,7 @@ type Props = { hierarchy: "primary" | "secondary" | "danger"; isDisabled?: boolean; size?: ButtonSize; - icon?: (props: IconProps) => JSX.Element; + icon?: (props: IconProps) => Children; iconPosition?: "leading" | "trailing"; } & Omit, "onPress"> & Pick, OtherButtonKeys>; diff --git a/packages/bento-design-system/src/DateField/Calendar.tsx b/packages/bento-design-system/src/DateField/Calendar.tsx index b0eaa2c64..c64d71a84 100644 --- a/packages/bento-design-system/src/DateField/Calendar.tsx +++ b/packages/bento-design-system/src/DateField/Calendar.tsx @@ -1,43 +1,38 @@ -import { MonthType, useMonth } from "@datepicker-react/hooks"; -import { useDateFormatter } from "@react-aria/i18n"; -import { useOverlay, useOverlayPosition } from "@react-aria/overlays"; -import { mergeProps } from "@react-aria/utils"; -import { RefObject, useRef } from "react"; -import { Box, Stack, Tiles } from ".."; -import { Label } from "../Typography/Label/Label"; -import { Children } from "../util/Children"; -import { useCreatePortal } from "../util/useCreatePortal"; -import { CalendarHeader } from "./CalendarHeader"; -import { calendar, weekDay } from "./DateField.css"; +import { useCalendar, useCalendarGrid, useRangeCalendar } from "@react-aria/calendar"; +import { + CalendarState, + RangeCalendarState, + useCalendarState, + useRangeCalendarState, +} from "@react-stately/calendar"; +import { createCalendar, getWeeksInMonth } from "@internationalized/date"; import { Day } from "./Day"; +import { useLocale } from "@react-aria/i18n"; +import { Box } from "../Box/Box"; +import { calendar, calendarGrid, weekDay } from "../DateField/DateField.css"; import { useBentoConfig } from "../BentoConfigContext"; +import { Children, Label, Stack } from ".."; import { getRadiusPropsFromConfig } from "../util/BorderRadiusConfig"; +import { CalendarHeader } from "./CalendarHeader"; +import { useCreatePortal } from "../util/useCreatePortal"; +import { useOverlay, useOverlayPosition } from "@react-aria/overlays"; +import { DOMAttributes, useRef } from "react"; +import { mergeProps } from "@react-aria/utils"; +import { AriaCalendarProps, AriaRangeCalendarProps, DateValue } from "@react-types/calendar"; +import { AriaButtonProps } from "@react-types/button"; +import { FocusableElement } from "@react-types/shared"; -export type CommonCalendarProps = { - inputRef: RefObject; - focusedDate: Date | null; - onDateFocus(date: Date): void; - onDateSelect(date: Date): void; - onDateHover(date: Date): void; - isStartDate(date: Date): boolean; - isEndDate(date: Date): boolean; - isDateFocused(date: Date): boolean; - isDateSelected(date: Date): boolean; - isDateHovered(date: Date): boolean; - isDateBlocked(date: Date): boolean; - isFirstOrLastSelectedDate(date: Date): boolean; -}; - -type Props = CommonCalendarProps & { - type: "single" | "range"; - activeDate: MonthType; - goToPreviousMonth: () => void; - goToNextMonth: () => void; - selectActiveDate: (date: Date) => void; +type Props = ( + | ({ + type: "single"; + } & AriaCalendarProps) + | ({ + type: "range"; + } & AriaRangeCalendarProps) +) & { onClose: () => void; - shortcuts?: Children; - minDate?: Date; - maxDate?: Date; + inputRef: React.RefObject; + shortcuts: Children; }; function boxShadowFromElevation(config: "none" | "small" | "medium" | "large") { @@ -53,19 +48,68 @@ function boxShadowFromElevation(config: "none" | "small" | "medium" | "large") { } } -export function Calendar(props: Props) { +function CalendarGrid( + props: + | { + type: "single"; + state: CalendarState; + } + | { type: "range"; state: RangeCalendarState } +) { + const { locale } = useLocale(); + const { gridProps, weekDays } = useCalendarGrid({}, props.state); const config = useBentoConfig().dateField; - const weekdayFormatter = useDateFormatter({ - weekday: "narrow", - }); - const overlayRef = useRef(null); - const createPortal = useCreatePortal(); - const { days, weekdayLabels } = useMonth({ - year: props.activeDate.year, - month: props.activeDate.month, - weekdayLabelFormat: (date) => weekdayFormatter.format(date), - }); + const weeksInMonth = getWeeksInMonth(props.state.visibleRange.start, locale); + + return ( + + {weekDays.map((day, index) => ( + + + + ))} + {[...new Array(weeksInMonth).keys()].map((weekIndex) => + props.state + .getDatesInWeek(weekIndex) + .map((date, i) => (date ? : )) + )} + + ); +} + +export function CalendarPopover( + props: ( + | { type: "single"; state: CalendarState } + | { type: "range"; state: RangeCalendarState } + ) & { + prevButtonProps: AriaButtonProps<"button">; + nextButtonProps: AriaButtonProps<"button">; + onClose: () => void; + calendarRef: React.RefObject; + inputRef: React.RefObject; + state: CalendarState | RangeCalendarState; + shortcuts?: Children; + } & DOMAttributes +) { + const { + prevButtonProps, + nextButtonProps, + onClose, + inputRef, + state, + calendarRef, + ...calendarProps + } = props; + const config = useBentoConfig().dateField; + + const createPortal = useCreatePortal(); const { overlayProps } = useOverlay( { @@ -73,51 +117,105 @@ export function Calendar(props: Props) { isDismissable: true, onClose: props.onClose, }, - overlayRef + calendarRef ); const { overlayProps: positionProps } = useOverlayPosition({ targetRef: props.inputRef, - overlayRef, + overlayRef: calendarRef, placement: "bottom start", offset: 35, isOpen: true, shouldFlip: true, }); + const gridProps = + props.type === "single" + ? { + type: "single" as const, + state: state as CalendarState, + } + : { type: "range" as const, state: state as RangeCalendarState }; + return createPortal( - - - {weekdayLabels.map((d, index) => ( - - - - ))} - {days.map((day, index) => { - if (typeof day === "object") { - return ; - } else { - return ( - - ); - } - })} - + + {props.shortcuts && {props.shortcuts}} ); } + +function SingleCalendar(props: Extract) { + const { locale } = useLocale(); + const state = useCalendarState({ + ...props, + locale, + createCalendar, + }); + const { calendarProps, prevButtonProps, nextButtonProps } = useCalendar(props, state); + const ref = useRef(null); + + return ( + + ); +} + +function RangeCalendar(props: Extract) { + const { locale } = useLocale(); + const state = useRangeCalendarState({ + ...props, + locale, + createCalendar, + }); + const ref = useRef(null); + const { calendarProps, prevButtonProps, nextButtonProps } = useRangeCalendar(props, state, ref); + + return ( + + ); +} + +export function Calendar(props: Props) { + if (props.type === "single") { + return ; + } else { + return ; + } +} diff --git a/packages/bento-design-system/src/DateField/CalendarHeader.tsx b/packages/bento-design-system/src/DateField/CalendarHeader.tsx index 6f2d71d5c..9c5ed29f3 100644 --- a/packages/bento-design-system/src/DateField/CalendarHeader.tsx +++ b/packages/bento-design-system/src/DateField/CalendarHeader.tsx @@ -1,56 +1,54 @@ -import { MonthType } from "@datepicker-react/hooks"; +import { AriaButtonProps } from "@react-types/button"; +import { Box } from "../Box/Box"; +import { Column, Columns } from "../Layout/Columns"; +import { IconButton } from "../"; +import { defaultMessages } from "../defaultMessages/en"; import { IconChevronLeft, IconChevronRight } from "../Icons"; -import { Box, Column, Columns, IconButton } from ".."; -import { useDefaultMessages } from "../util/useDefaultMessages"; import { Selector } from "./Selector"; +import { CalendarDate } from "@internationalized/date"; +import { DateValue } from "@react-aria/calendar"; type Props = { - activeDate: MonthType; - goToPreviousMonth: () => void; - goToNextMonth: () => void; - selectActiveDate: (date: Date) => void; - minDate?: Date; - maxDate?: Date; + prevButtonProps: AriaButtonProps<"button">; + nextButtonProps: AriaButtonProps<"button">; + focusedDate: CalendarDate; + onChange: (date: CalendarDate) => void; + minDate?: DateValue; + maxDate?: DateValue; }; -export function CalendarHeader({ - goToPreviousMonth, - goToNextMonth, - selectActiveDate, - activeDate, - minDate, - maxDate, -}: Props) { - const { defaultMessages } = useDefaultMessages(); +export function CalendarHeader(props: Props) { return ( - - + + - + diff --git a/packages/bento-design-system/src/DateField/Config.ts b/packages/bento-design-system/src/DateField/Config.ts index 6af391047..22181720c 100644 --- a/packages/bento-design-system/src/DateField/Config.ts +++ b/packages/bento-design-system/src/DateField/Config.ts @@ -4,7 +4,6 @@ import { BodyProps } from "../Typography/Body/Body"; import { LabelProps } from "../Typography/Label/Label"; import { BorderRadiusConfig } from "../util/BorderRadiusConfig"; import { Children } from "../util/Children"; -import { vars } from "../vars.css"; export type DateFieldConfig = { radius: BorderRadiusConfig; @@ -18,8 +17,8 @@ export type DateFieldConfig = { open: (props: IconProps) => Children; close: (props: IconProps) => Children; }; - dayWidth: keyof typeof vars.space; - dayHeight: keyof typeof vars.space; + dayWidth: number; + dayHeight: number; dayRadius: BorderRadiusConfig; daySize: BodyProps["size"]; }; diff --git a/packages/bento-design-system/src/DateField/DateField.css.ts b/packages/bento-design-system/src/DateField/DateField.css.ts index e6531c003..80bb7e9ad 100644 --- a/packages/bento-design-system/src/DateField/DateField.css.ts +++ b/packages/bento-design-system/src/DateField/DateField.css.ts @@ -1,6 +1,7 @@ import { createVar, style } from "@vanilla-extract/css"; import { bentoSprinkles } from "../internal"; import { strictRecipe } from "../util/strictRecipe"; +import { vars } from "../vars.css"; export const topLeftRadius = createVar(); export const topRightRadius = createVar(); @@ -18,6 +19,15 @@ export const calendar = bentoSprinkles({ borderWidth: 1, }); +export const calendarGrid = style([ + bentoSprinkles({ + display: "grid", + }), + { + gridTemplateColumns: "repeat(7, 1fr)", + }, +]); + export const dateFieldRecipe = strictRecipe({ variants: { validation: { @@ -25,7 +35,7 @@ export const dateFieldRecipe = strictRecipe({ invalid: {}, notSet: {}, }, - isFocused: { + isCalendarOpen: { true: {}, }, }, @@ -33,7 +43,7 @@ export const dateFieldRecipe = strictRecipe({ { variants: { validation: "valid", - isFocused: true, + isCalendarOpen: true, }, style: bentoSprinkles({ boxShadow: { default: "outlineInputFocus", hover: "outlineInputFocus" }, @@ -42,7 +52,7 @@ export const dateFieldRecipe = strictRecipe({ { variants: { validation: "invalid", - isFocused: true, + isCalendarOpen: true, }, style: bentoSprinkles({ boxShadow: { default: "outlineNegativeStrong", hover: "outlineNegativeStrong" }, @@ -141,13 +151,21 @@ export const dayRecipe = strictRecipe({ }, }); -export const selector = bentoSprinkles({ - paddingX: 16, - paddingY: 8, - borderRadius: 4, - background: { - default: "secondaryTransparentEnabledBackground", - focus: "secondaryTransparentFocusBackground", - hover: "secondaryTransparentHoverBackground", +export const dateSegment = style([ + bentoSprinkles({ outline: { focus: "none" }, textTransform: "uppercase" }), + { + selectors: { + "&[data-placeholder=true]": { + color: vars.textColor.textSecondary, + }, + "&[data-placeholder=true][aria-disabled=true]": { + color: vars.textColor.textDisabled, + }, + + "&:focus:not([readonly])": { + borderBottom: `1px solid ${vars.textColor.textSecondary}`, + marginBottom: "-1px", + }, + }, }, -}); +]); diff --git a/packages/bento-design-system/src/DateField/DateField.tsx b/packages/bento-design-system/src/DateField/DateField.tsx index ef9da193d..6f5e4ce14 100644 --- a/packages/bento-design-system/src/DateField/DateField.tsx +++ b/packages/bento-design-system/src/DateField/DateField.tsx @@ -1,18 +1,16 @@ +import { useDatePicker, useDateRangePicker } from "@react-aria/datepicker"; +import { useDatePickerState, useDateRangePickerState } from "@react-stately/datepicker"; +import { useRef } from "react"; import { FieldProps } from "../Field/FieldProps"; -import { useRef, useState } from "react"; -import { inputRecipe } from "../Field/Field.css"; -import { bodyRecipe } from "../Typography/Body/Body.css"; -import { FocusedInput, useDatepicker, UseDatepickerProps } from "@datepicker-react/hooks"; -import { Calendar } from "./Calendar"; -import { Box, Button, Column, Columns, Field, Inline } from ".."; -import { useField } from "@react-aria/label"; +import { CalendarDate, DateValue, getLocalTimeZone } from "@internationalized/date"; import { Input } from "./Input"; -import { IconMinus } from "../Icons"; -import { dateFieldRecipe } from "./DateField.css"; -import { LocalizedString } from "../util/LocalizedString"; -import { useBentoConfig } from "../BentoConfigContext"; -import { getReadOnlyBackgroundStyle } from "../Field/utils"; -import { getRadiusPropsFromConfig } from "../util/BorderRadiusConfig"; +import { Calendar } from "./Calendar"; +import { Box } from "../Box/Box"; +import { Field } from "../Field/Field"; +import { LocalizedString } from "../util/ConfigurableTypes"; +import { RangeValue } from "@react-types/shared"; +import { Inline } from "../Layout/Inline"; +import { Button } from ".."; export type ShortcutProps = { label: LocalizedString; @@ -25,104 +23,134 @@ type SingleDateFieldProps = { type RangeDateFieldProps = { type: "range"; shortcuts?: ShortcutProps<[Date, Date]>[]; -} & FieldProps<[Date | null, Date | null]>; +} & FieldProps<[Date, Date] | null>; type Props = (SingleDateFieldProps | RangeDateFieldProps) & { - minDate?: UseDatepickerProps["minBookingDate"]; - maxDate?: UseDatepickerProps["maxBookingDate"]; - shouldDisableDate?: UseDatepickerProps["isDateBlocked"]; + minDate?: Date; + maxDate?: Date; + shouldDisableDate?: (date: Date) => boolean; readOnly?: boolean; }; -export function DateField(props: Props) { - const inputConfig = useBentoConfig().input; - const startInputRef = useRef(null); - const endInputRef = useRef(null); - const [focusedInput, setFocusedInput] = useState( - props.autoFocus ? "startDate" : null - ); - const [isFocused, setIsFocused] = useState(props.autoFocus ?? false); - const [isOpen, setIsOpen] = useState(props.autoFocus ?? false); - const validationState = props.readOnly ? "notSet" : props.issues ? "invalid" : "valid"; +function dateToCalendarDate(date: Date): CalendarDate { + return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate()); +} - const { - goToDate, - activeMonths, - goToNextMonths, - goToPreviousMonths, - focusedDate, - isDateFocused, - isDateBlocked, - isDateHovered, - isDateSelected, - isFirstOrLastSelectedDate, - onDateFocus, - onDateHover, - onDateSelect, - isStartDate, - isEndDate, - } = useDatepicker({ - onDatesChange: ({ startDate, endDate, focusedInput }) => { - const newFocusedInput = - props.type === "range" || focusedInput !== "endDate" ? focusedInput : null; - if (newFocusedInput === null) { - setIsOpen(false); - startInputRef.current && startInputRef.current.focus(); - } - if (newFocusedInput === "endDate" && endInputRef.current) endInputRef.current.focus(); - if (newFocusedInput === "startDate" && startInputRef.current) startInputRef.current.focus(); +function SingleDateField({ disabled, readOnly, ...props }: Extract) { + const localTimeZone = getLocalTimeZone(); - if (props.type === "range") { - props.onChange([startDate, endDate]); - } else { - props.onChange(startDate); - } + const internalProps = { + ...props, + value: props.value ? dateToCalendarDate(props.value) : props.value, + onChange: (date: CalendarDate | null) => { + props.onChange(date?.toDate(localTimeZone) ?? null); }, - startDate: props.type === "range" ? props.value[0] : props.value, - endDate: props.type === "range" ? props.value[1] : null, - isDateBlocked: props.shouldDisableDate, - focusedInput, - numberOfMonths: 1, - minBookingDate: props.minDate, - maxBookingDate: props.maxDate, - }); + isDisabled: disabled, + isReadOnly: readOnly, + validationState: props.issues ? "invalid" : "valid", + minValue: props.minDate ? dateToCalendarDate(props.minDate) : undefined, + maxValue: props.maxDate ? dateToCalendarDate(props.maxDate) : undefined, + isDateUnavailable: props.shouldDisableDate + ? (date: DateValue) => props.shouldDisableDate!(date.toDate(localTimeZone)) + : undefined, + shouldForceLeadingZeros: true, + } as const; + const state = useDatePickerState(internalProps); + const ref = useRef(null); + const { + groupProps, + labelProps, + fieldProps, + buttonProps, + descriptionProps, + errorMessageProps, + calendarProps, + } = useDatePicker(internalProps, state, ref); - const selectDate = (date: Date) => { - if (!isDateBlocked(date)) { - onDateSelect(date); - onDateFocus(date); - } - }; + const shortcuts = props.shortcuts && ( + + {props.shortcuts.map((shortcut) => ( +