diff --git a/packages/lsd-react/package.json b/packages/lsd-react/package.json index b6d2a70..5c943ec 100644 --- a/packages/lsd-react/package.json +++ b/packages/lsd-react/package.json @@ -14,6 +14,7 @@ "prepublish": "yarn build" }, "dependencies": { + "@datepicker-react/hooks": "^2.8.4", "@emotion/react": "^11.10.5", "@emotion/styled": "^11.10.5", "clsx": "^1.2.1", diff --git a/packages/lsd-react/src/components/CSSBaseline/CSSBaseline.tsx b/packages/lsd-react/src/components/CSSBaseline/CSSBaseline.tsx index 8043bfc..f0e0388 100644 --- a/packages/lsd-react/src/components/CSSBaseline/CSSBaseline.tsx +++ b/packages/lsd-react/src/components/CSSBaseline/CSSBaseline.tsx @@ -5,6 +5,7 @@ import { BadgeStyles } from '../Badge/Badge.styles' import { BreadcrumbStyles } from '../Breadcrumb/Breadcrumb.styles' import { BreadcrumbItemStyles } from '../BreadcrumbItem/BreadcrumbItem.styles' import { ButtonStyles } from '../Button/Button.styles' +import { CalendarStyles } from '../Calendar/Calendar.styles' import { CardStyles } from '../Card/Card.styles' import { CardBodyStyles } from '../CardBody/CardBody.styles' import { CardHeaderStyles } from '../CardHeader/CardHeader.styles' @@ -12,6 +13,8 @@ import { CheckboxStyles } from '../Checkbox/Checkbox.styles' import { CheckboxGroupStyles } from '../CheckboxGroup/CheckboxGroup.styles' import { CollapseStyles } from '../Collapse/Collapse.styles' import { CollapseHeaderStyles } from '../CollapseHeader/CollapseHeader.styles' +import { DateFieldStyles } from '../DateField/DateField.styles' +import { DatePickerStyles } from '../DatePicker/DatePicker.styles' import { DropdownStyles } from '../Dropdown/Dropdown.styles' import { DropdownItemStyles } from '../DropdownItem/DropdownItem.styles' import { IconButtonStyles } from '../IconButton/IconButton.styles' @@ -74,6 +77,9 @@ const componentStyles: Array | SerializedStyles> = ModalStyles, ModalFooterStyles, ModalBodyStyles, + DatePickerStyles, + DateFieldStyles, + CalendarStyles, ] export const CSSBaseline: React.FC<{ theme?: Theme }> = ({ diff --git a/packages/lsd-react/src/components/Calendar/Calendar.classes.ts b/packages/lsd-react/src/components/Calendar/Calendar.classes.ts new file mode 100644 index 0000000..0e1e79a --- /dev/null +++ b/packages/lsd-react/src/components/Calendar/Calendar.classes.ts @@ -0,0 +1,24 @@ +export const calendarClasses = { + root: `lsd-calendar`, + container: 'lsd-calendar-container', + + open: 'lsd-calendar--open', + disabled: 'lsd-calendar--disabled', + + header: 'lsd-calendar-header', + grid: 'lsd-calendar-body', + weekDay: 'lsd-calendar__week_day', + button: 'lsd-calendar__button', + row: 'lsd-calendar__row', + changeYear: 'lsd-calendar__change-year', + changeYearButton: 'lsd-calendar__change-year__button', + + year: 'lsd-calendar-year', + month: 'lsd-calendar-month', + day: 'lsd-calendar-day', + daySelected: 'lsd-calendar-day--selected', + dayDisabled: 'lsd-calendar-day--disabled', + dayIsToday: 'lsd-calendar-day--today', + todayIndicator: 'lsd-calendar-day__today_indicator', + dayRange: 'lsd-calendar-day--range', +} diff --git a/packages/lsd-react/src/components/Calendar/Calendar.context.ts b/packages/lsd-react/src/components/Calendar/Calendar.context.ts new file mode 100644 index 0000000..46cad87 --- /dev/null +++ b/packages/lsd-react/src/components/Calendar/Calendar.context.ts @@ -0,0 +1,20 @@ +import React from 'react' + +export type CalendarContextType = { + focusedDate: Date | null + size?: 'large' | 'medium' | 'small' + isDateFocused: (date: Date) => boolean + isDateSelected: (date: Date) => boolean + isDateHovered: (date: Date) => boolean + isDateBlocked: (date: Date) => boolean + isFirstOrLastSelectedDate: (date: Date) => boolean + onDateFocus: (date: Date) => void + onDateHover: (date: Date) => void + onDateSelect: (date: Date) => void +} + +export const CalendarContext = React.createContext( + null as any, +) + +export const useCalendarContext = () => React.useContext(CalendarContext) diff --git a/packages/lsd-react/src/components/Calendar/Calendar.stories.tsx b/packages/lsd-react/src/components/Calendar/Calendar.stories.tsx new file mode 100644 index 0000000..0b49344 --- /dev/null +++ b/packages/lsd-react/src/components/Calendar/Calendar.stories.tsx @@ -0,0 +1,53 @@ +import { Meta, Story } from '@storybook/react' +import { useRef } from 'react' +import { Calendar, CalendarProps } from './Calendar' + +export default { + title: 'Calendar', + component: Calendar, + argTypes: { + size: { + type: { + name: 'enum', + value: ['small', 'medium', 'large'], + }, + defaultValue: 'large', + }, + }, +} as Meta + +export const Uncontrolled: Story = (arg) => { + const ref = useRef(null) + + return ( +
+ + Calendar + +
+ ) +} + +export const Controlled: Story = (arg) => { + const ref = useRef(null) + + return ( +
+ + Calendar + +
+ ) +} + +Uncontrolled.args = { + value: undefined, + onChange: undefined, + size: 'large', +} + +Controlled.args = { + value: '2023-01-01', + onChange: undefined, + size: 'large', +} diff --git a/packages/lsd-react/src/components/Calendar/Calendar.styles.ts b/packages/lsd-react/src/components/Calendar/Calendar.styles.ts new file mode 100644 index 0000000..eb09c4b --- /dev/null +++ b/packages/lsd-react/src/components/Calendar/Calendar.styles.ts @@ -0,0 +1,147 @@ +import { css } from '@emotion/react' +import { calendarClasses } from './Calendar.classes' + +export const CalendarStyles = css` + .${calendarClasses.root} { + border: 1px solid rgb(var(--lsd-border-primary)); + visibility: hidden; + position: absolute; + top: 0; + left: 0; + opacity: 0; + visibility: hidden; + margin: 0; + padding: 0; + box-sizing: border-box; + background: rgb(var(--lsd-surface-primary)); + } + + .${calendarClasses.container} { + display: grid; + margin: 8px 2px 2px; + grid-gap: 0 64px; + } + + .${calendarClasses.open} { + opacity: 1; + visibility: visible; + } + + .${calendarClasses.header} { + display: flex; + justify-content: space-between; + align-items: center; + padding-inline: 10px; + padding-bottom: 10px; + } + + .${calendarClasses.grid} { + display: grid; + grid-template-columns: repeat(7, 1fr); + justify-content: center; + cursor: pointer; + } + + .${calendarClasses.weekDay} { + text-align: center; + aspect-ratio: 1 / 1; + display: flex; + justify-content: center; + align-items: center; + } + + .${calendarClasses.row} { + display: flex; + justify-content: center; + align-items: center; + } + + .${calendarClasses.changeYear} { + display: flex; + justify-content: center; + align-items: center; + border: 1px solid rgb(var(--lsd-border-primary)); + padding: 2px 6px; + gap: 6px; + } + + .${calendarClasses.changeYearButton} { + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + border: none; + height: 14px; + width: 14px; + padding: 0; + } + + .${calendarClasses.month} { + margin-right: 8px; + } + + .${calendarClasses.year}:hover { + cursor: pointer; + text-decoration: underline; + text-decoration-color: rgb(var(--lsd-border-primary)); + } + + .${calendarClasses.day} { + cursor: pointer; + border: none; + background: transparent; + aspect-ratio: 1 / 1; + position: relative; + } + + .${calendarClasses.day}:hover { + cursor: pointer; + text-decoration: underline; + text-decoration-color: rgb(var(--lsd-border-primary)); + } + + .${calendarClasses.day} label:hover { + cursor: pointer; + } + + .${calendarClasses.daySelected} { + border: 1px solid rgb(var(--lsd-border-primary)); + } + + .${calendarClasses.dayDisabled} { + opacity: 0.3; + cursor: default; + } + + .${calendarClasses.todayIndicator} { + position: absolute; + left: 50%; + transform: translateX(-50%); + bottom: 2px; + } + + .${calendarClasses.disabled} { + pointer-events: none; + border: 1px solid rgba(var(--lsd-border-primary), 0.3); + label { + opacity: 0.3; + } + .${calendarClasses.button} { + opacity: 0.3; + } + .${calendarClasses.daySelected} { + opacity: 0.3; + } + } + + .${calendarClasses.button} { + border: 1px solid rgb(var(--lsd-border-primary)); + cursor: pointer; + background: transparent; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + } +` diff --git a/packages/lsd-react/src/components/Calendar/Calendar.tsx b/packages/lsd-react/src/components/Calendar/Calendar.tsx new file mode 100644 index 0000000..2a3172e --- /dev/null +++ b/packages/lsd-react/src/components/Calendar/Calendar.tsx @@ -0,0 +1,164 @@ +import { + OnDatesChangeProps, + START_DATE, + useDatepicker, +} from '@datepicker-react/hooks' +import clsx from 'clsx' +import React, { useEffect, useRef, useState } from 'react' +import { useClickAway } from 'react-use' +import { safeConvertDateToString } from '../../utils/date.utils' +import { calendarClasses } from './Calendar.classes' +import { CalendarContext } from './Calendar.context' +import { Month } from './Month' + +export type CalendarProps = Omit< + React.HTMLAttributes, + 'label' | 'onChange' +> & { + open?: boolean + disabled?: boolean + value?: string + onChange: (data: Date) => void + handleRef: React.RefObject + size?: 'large' | 'medium' | 'small' + onClose?: () => void + onCalendarClickaway?: (event: Event) => void + minDate?: Date + maxDate?: Date +} + +export const Calendar: React.FC & { + classes: typeof calendarClasses +} = ({ + open, + handleRef, + value: valueProp, + size = 'large', + disabled = false, + onChange, + onClose, + onCalendarClickaway, + // minDate and maxDate are necessary because onDateFocus freaks out with small/large date values. + minDate = new Date(1900, 0, 1), + maxDate = new Date(2100, 0, 1), + children, + ...props +}) => { + const ref = useRef(null) + const [style, setStyle] = useState({}) + const [value, setValue] = useState( + valueProp + ? safeConvertDateToString(valueProp, minDate, maxDate).date + : null, + ) + const isOpenControlled = typeof open !== 'undefined' + + useClickAway(ref, (event) => { + if (!open) return + + onCalendarClickaway && onCalendarClickaway(event) + + if (isOpenControlled) return + + onClose && onClose() + }) + + const handleDateChange = (data: OnDatesChangeProps) => { + if (typeof valueProp !== 'undefined') + return onChange?.(data.startDate ?? new Date()) + + setValue(data.startDate) + } + + const { + activeMonths, + isDateSelected, + isDateHovered, + isFirstOrLastSelectedDate, + isDateBlocked, + isDateFocused, + focusedDate, + onDateHover, + onDateSelect, + onDateFocus, + goToPreviousMonths, + goToNextMonths, + } = useDatepicker({ + startDate: value ? new Date(value) : null, + endDate: null, + focusedInput: START_DATE, + onDatesChange: handleDateChange, + numberOfMonths: 1, + }) + + useEffect(() => { + onDateFocus(value ? new Date(value) : new Date()) + }, [value]) + + useEffect(() => { + if (typeof valueProp === 'undefined') return + + const { date } = safeConvertDateToString(valueProp, minDate, maxDate) + setValue(date) + }, [valueProp]) + + const updateStyle = () => { + const { width, height, top, left } = + handleRef.current!.getBoundingClientRect() + + setStyle({ + left, + width, + top: top + height, + }) + } + + useEffect(() => { + updateStyle() + }, [open]) + + return ( + +
+
+ {activeMonths.map((month, idx) => ( + + ))} +
+
+
+ ) +} + +Calendar.classes = calendarClasses diff --git a/packages/lsd-react/src/components/Calendar/Day.tsx b/packages/lsd-react/src/components/Calendar/Day.tsx new file mode 100644 index 0000000..f5120f4 --- /dev/null +++ b/packages/lsd-react/src/components/Calendar/Day.tsx @@ -0,0 +1,72 @@ +import { useRef, useContext } from 'react' +import { useDay } from '@datepicker-react/hooks' +import { CalendarContext } from './Calendar.context' +import clsx from 'clsx' +import { calendarClasses } from './Calendar.classes' +import { Typography } from '../Typography' + +export type DayProps = { + day?: string + date: Date + disabled?: boolean +} + +export const Day = ({ day, date, disabled = false }: DayProps) => { + const dayRef = useRef(null) + const { + focusedDate, + isDateFocused, + isDateSelected, + isDateHovered, + isDateBlocked, + isFirstOrLastSelectedDate, + onDateSelect, + onDateFocus, + onDateHover, + } = useContext(CalendarContext) + + const { onClick, onKeyDown, onMouseEnter, tabIndex } = useDay({ + date, + focusedDate, + isDateFocused, + isDateSelected, + isDateHovered, + isDateBlocked, + isFirstOrLastSelectedDate, + onDateFocus, + onDateSelect, + onDateHover, + dayRef, + }) + + if (!day) { + return null + } + + const isToday = + new Date(date).setHours(0, 0, 0, 0) === new Date().setHours(0, 0, 0, 0) + + return ( + + ) +} diff --git a/packages/lsd-react/src/components/Calendar/Month.tsx b/packages/lsd-react/src/components/Calendar/Month.tsx new file mode 100644 index 0000000..8731ac3 --- /dev/null +++ b/packages/lsd-react/src/components/Calendar/Month.tsx @@ -0,0 +1,181 @@ +import { FirstDayOfWeek, useMonth } from '@datepicker-react/hooks' +import clsx from 'clsx' +import { useRef, useState } from 'react' +import { useClickAway } from 'react-use' +import { IconButton } from '../IconButton' +import { + ArrowDownIcon, + ArrowUpIcon, + NavigateBeforeIcon, + NavigateNextIcon, +} from '../Icons' +import { Typography } from '../Typography' +import { calendarClasses } from './Calendar.classes' +import { useCalendarContext } from './Calendar.context' +import { Day } from './Day' + +export type MonthProps = { + year: number + month: number + firstDayOfWeek: FirstDayOfWeek + goToPreviousMonths: () => void + goToNextMonths: () => void + size?: 'large' | 'medium' | 'small' +} + +export const Month = ({ + size: _size = 'large', + year: _year, + month, + firstDayOfWeek, + goToPreviousMonths, + goToNextMonths, +}: MonthProps) => { + const sizeContext = useCalendarContext() + const size = sizeContext?.size ?? _size + const [year, setYear] = useState(_year) + const { days, weekdayLabels, monthLabel } = useMonth({ + year, + month, + firstDayOfWeek, + }) + const [changeYear, setChangeYear] = useState(false) + const ref = useRef(null) + + const renderOtherDays = (idx: number, referenceDate: Date) => { + const date = new Date(referenceDate) + date.setDate(date.getDate() + idx) + return date.getDate() + } + + const getDay = (index: number) => + days[index] as { + dayLabel: string + date: Date + } + + useClickAway(ref, (event) => { + if (!changeYear) return + + setChangeYear(false) + }) + + return ( + <> +
+ +
+ + {monthLabel.split(' ')[0]} + + {changeYear ? ( +
+ + {monthLabel.split(' ')[1]} + +
+ setYear(year + 1)} + className={calendarClasses.changeYearButton} + > + + + setYear(year - 1)} + className={calendarClasses.changeYearButton} + > + + +
+
+ ) : ( + setChangeYear(true)} + variant={size === 'large' ? 'label1' : 'label2'} + className={calendarClasses.year} + > + {monthLabel.split(' ')[1]} + + )} +
+ +
+
+ {weekdayLabels.map((dayLabel, idx) => ( + + {dayLabel[0]} + + ))} +
+
+ {days.length == 28 && + new Array(7) + .fill(null) + .map((_, idx) => ( + + ))} + {days.map((ele, idx) => + typeof ele !== 'number' ? ( + + ) : ( + day === 0).length, + getDay(days.lastIndexOf(0) + 1).date, + ).toString()} + key={`current-${idx}`} + disabled={true} + /> + ), + )} + {new Array( + days.length % 7 !== 0 && days.length <= 35 + ? 7 - (days.length % 7) + 7 + : 7 - (days.length % 7), + ) + .fill(null) + .map((ele, idx) => ( + + ))} +
+ + ) +} diff --git a/packages/lsd-react/src/components/Calendar/index.ts b/packages/lsd-react/src/components/Calendar/index.ts new file mode 100644 index 0000000..8c50cf8 --- /dev/null +++ b/packages/lsd-react/src/components/Calendar/index.ts @@ -0,0 +1 @@ +export * from './Calendar' diff --git a/packages/lsd-react/src/components/DateField/DateField.classes.ts b/packages/lsd-react/src/components/DateField/DateField.classes.ts new file mode 100644 index 0000000..f86a3ef --- /dev/null +++ b/packages/lsd-react/src/components/DateField/DateField.classes.ts @@ -0,0 +1,22 @@ +export const dateFieldClasses = { + root: `lsd-date-field`, + label: 'lsd-date-field__label', + + inputContainer: `lsd-date-field__input-container`, + input: `lsd-date-field__input-container__input`, + inputFilled: `lsd-date-field__input-container__input--filled`, + icon: `lsd-date-field__input-container__icon`, + iconButton: `lsd-date-field__input-container__icon-button`, + + supportingText: 'lsd-date-field__supporting-text', + + disabled: `lsd-date-field--disabled`, + error: 'lsd-date-field--error', + + large: `lsd-date-field--large`, + medium: `lsd-date-field--medium`, + small: `lsd-date-field--small`, + + outlined: `lsd-date-field--outlined`, + outlinedBottom: `lsd-date-field--outlined-bottom`, +} diff --git a/packages/lsd-react/src/components/DateField/DateField.stories.tsx b/packages/lsd-react/src/components/DateField/DateField.stories.tsx new file mode 100644 index 0000000..be8b6f8 --- /dev/null +++ b/packages/lsd-react/src/components/DateField/DateField.stories.tsx @@ -0,0 +1,59 @@ +import { Meta, Story } from '@storybook/react' +import { DateField, DateFieldProps } from './DateField' + +export default { + title: 'DateField', + component: DateField, + argTypes: { + size: { + type: { + name: 'enum', + value: ['small', 'medium', 'large'], + }, + defaultValue: 'large', + }, + variant: { + type: { + name: 'enum', + value: ['outlined', 'outlined-bottom'], + }, + defaultValue: 'large', + }, + }, +} as Meta + +export const Uncontrolled: Story = ({ ...args }) => { + return +} + +export const Controlled: Story = ({ ...args }) => { + return +} + +Uncontrolled.args = { + id: 'label', + size: 'large', + supportingText: 'Supporting text', + disabled: false, + value: undefined, + onChange: undefined, + error: false, + errorIcon: false, + clearButton: true, + variant: 'outlined-bottom', + label: 'Label', +} + +Controlled.args = { + id: 'label', + size: 'large', + supportingText: 'Supporting text', + disabled: false, + value: '2023-01-01', + onChange: undefined, + error: false, + errorIcon: false, + clearButton: true, + variant: 'outlined-bottom', + label: 'Label', +} diff --git a/packages/lsd-react/src/components/DateField/DateField.styles.ts b/packages/lsd-react/src/components/DateField/DateField.styles.ts new file mode 100644 index 0000000..00af25d --- /dev/null +++ b/packages/lsd-react/src/components/DateField/DateField.styles.ts @@ -0,0 +1,141 @@ +import { css } from '@emotion/react' +import { dateFieldClasses } from './DateField.classes' + +export const DateFieldStyles = css` + .${dateFieldClasses.root} { + width: auto; + box-sizing: border-box; + } + + .${dateFieldClasses.label} { + display: block; + } + + .${dateFieldClasses.icon} { + cursor: pointer; + display: flex; + align-items: center; + } + + .${dateFieldClasses.outlined} { + border: 1px solid rgb(var(--lsd-border-primary)); + } + + .${dateFieldClasses.outlinedBottom} { + border-bottom: 1px solid rgb(var(--lsd-border-primary)); + } + + .${dateFieldClasses.inputContainer} { + display: flex; + align-items: center; + justify-content: space-between; + } + + .${dateFieldClasses.disabled} { + opacity: 0.34; + } + + .${dateFieldClasses.input} { + border: none; + outline: none; + font-size: 14px; + color: rgb(var(--lsd-text-primary)); + background: none; + width: 100%; + opacity: 0.4; + transition: opacity 0.2s ease-in-out; + } + + .${dateFieldClasses.input}::-webkit-inner-spin-button, + .${dateFieldClasses.input}::-webkit-calendar-picker-indicator { + display: none; + -webkit-appearance: none; + } + + .${dateFieldClasses.input}:hover { + outline: none; + } + + .${dateFieldClasses.supportingText} { + position: absolute; + } + + .${dateFieldClasses.large} { + width: 208px; + + .${dateFieldClasses.label} { + margin: 0 0 6px 18px; + } + .${dateFieldClasses.inputContainer} { + height: 40px; + } + .${dateFieldClasses.input} { + padding: 9px 13px 9px 17px; + } + .${dateFieldClasses.icon} { + padding: 12px 13px; + } + .${dateFieldClasses.supportingText} { + margin: 6px 18px 0 18px; + } + } + + .${dateFieldClasses.medium} { + width: 188px; + .${dateFieldClasses.label} { + margin: 0 0 6px 14px; + } + .${dateFieldClasses.inputContainer} { + height: 32px; + } + .${dateFieldClasses.input} { + padding: 5px 11px 5px 13px; + } + .${dateFieldClasses.icon} { + padding: 8px 11px; + } + .${dateFieldClasses.supportingText} { + margin: 6px 14px 0 14px; + } + } + + .${dateFieldClasses.small} { + width: 164px; + .${dateFieldClasses.label} { + margin: 0 0 6px 12px; + } + .${dateFieldClasses.inputContainer} { + height: 28px; + } + .${dateFieldClasses.input} { + padding: 5px 9px 5px 11px; + } + .${dateFieldClasses.icon} { + padding: 6px 9px; + } + .${dateFieldClasses.supportingText} { + margin: 6px 12px 0 12px; + } + } + + .${dateFieldClasses.input}:invalid, .${dateFieldClasses.inputFilled} { + color: rgb(var(--lsd-border-primary)); + opacity: 1; + } + + .${dateFieldClasses.error} + .${dateFieldClasses.input}::-webkit-datetime-edit-year-field, + .${dateFieldClasses.error} + .${dateFieldClasses.input}::-webkit-datetime-edit-month-field, + .${dateFieldClasses.error} + .${dateFieldClasses.input}::-webkit-datetime-edit-day-field { + text-decoration: line-through; + } + + /* If browser does not support ::-webkit-datetime pseudo elements, use the following. */ + @supports not selector(::-webkit-datetime-edit-day-field) { + .${dateFieldClasses.error} .${dateFieldClasses.input} { + text-decoration: line-through; + } + } +` diff --git a/packages/lsd-react/src/components/DateField/DateField.tsx b/packages/lsd-react/src/components/DateField/DateField.tsx new file mode 100644 index 0000000..e47e8fe --- /dev/null +++ b/packages/lsd-react/src/components/DateField/DateField.tsx @@ -0,0 +1,141 @@ +import clsx from 'clsx' +import React, { useRef } from 'react' +import { useInput } from '../../utils/useInput' +import { CloseIcon, ErrorIcon } from '../Icons' +import { Typography } from '../Typography' +import { dateFieldClasses } from './DateField.classes' + +export type DateFieldProps = Omit< + React.HTMLAttributes, + 'onChange' | 'value' +> & + Pick, 'onChange'> & { + label?: React.ReactNode + size?: 'large' | 'medium' | 'small' + error?: boolean + errorIcon?: boolean + clearButton?: boolean + disabled?: boolean + supportingText?: string + value?: string + defaultValue?: string + placeholder?: string + icon?: React.ReactNode + onIconClick?: () => void + inputProps?: React.InputHTMLAttributes + variant?: 'outlined' | 'outlined-bottom' + calendarIconRef?: React.RefObject + } + +export const DateField: React.FC & { + classes: typeof dateFieldClasses +} = ({ + label, + size = 'large', + error = false, + errorIcon = false, + clearButton, + supportingText, + children, + value, + placeholder, + defaultValue, + disabled, + onChange, + icon, + onIconClick, + inputProps = {}, + variant = 'outlined-bottom', + ...props +}) => { + const ref = useRef(null) + const input = useInput({ + defaultValue, + value, + onChange, + ref, + }) + + const onCancel = () => input.setValue('') + + const inputId = inputProps?.id ?? (props.id || 'date-field') + '-input' + + return ( +
+ {label && ( + + {label} + + )} +
+ + {icon ? ( + !disabled && onIconClick && onIconClick()} + ref={props.calendarIconRef} + > + {icon} + + ) : error && errorIcon ? ( + + + + ) : clearButton && input.filled ? ( + !disabled && onCancel()} + className={dateFieldClasses.icon} + > + + + ) : null} +
+ {supportingText && ( +
+ + {supportingText} + +
+ )} + {children} +
+ ) +} + +DateField.classes = dateFieldClasses diff --git a/packages/lsd-react/src/components/DateField/index.ts b/packages/lsd-react/src/components/DateField/index.ts new file mode 100644 index 0000000..f865b34 --- /dev/null +++ b/packages/lsd-react/src/components/DateField/index.ts @@ -0,0 +1 @@ +export * from './DateField' diff --git a/packages/lsd-react/src/components/DatePicker/DatePicker.classes.ts b/packages/lsd-react/src/components/DatePicker/DatePicker.classes.ts new file mode 100644 index 0000000..1e0e2d1 --- /dev/null +++ b/packages/lsd-react/src/components/DatePicker/DatePicker.classes.ts @@ -0,0 +1,9 @@ +export const datePickerClasses = { + root: `lsd-date-picker`, + + calendar: `lsd-date-picker__calendar`, + + large: `lsd-date-picker--large`, + medium: `lsd-date-picker--medium`, + small: `lsd-date-picker--small`, +} diff --git a/packages/lsd-react/src/components/DatePicker/DatePicker.stories.tsx b/packages/lsd-react/src/components/DatePicker/DatePicker.stories.tsx new file mode 100644 index 0000000..cdc9319 --- /dev/null +++ b/packages/lsd-react/src/components/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,61 @@ +import { Meta, Story } from '@storybook/react' +import { DatePicker, DatePickerProps } from './DatePicker' + +export default { + title: 'DatePicker', + component: DatePicker, + argTypes: { + size: { + type: { + name: 'enum', + value: ['small', 'medium', 'large'], + }, + defaultValue: 'large', + }, + variant: { + type: { + name: 'enum', + value: ['outlined', 'outlined-bottom'], + }, + defaultValue: 'large', + }, + }, +} as Meta + +export const Uncontrolled: Story = ({ ...args }) => { + return DatePicker +} + +export const Controlled: Story = ({ ...args }) => { + return DatePicker +} + +Uncontrolled.args = { + id: 'label', + supportingText: 'Supporting text', + disabled: false, + error: false, + value: undefined, + onChange: undefined, + errorIcon: false, + clearButton: true, + withCalendar: true, + size: 'large', + variant: 'outlined-bottom', + label: 'Label', +} + +Controlled.args = { + id: 'label', + supportingText: 'Supporting text', + disabled: false, + error: false, + value: '2023-01-01', + onChange: undefined, + errorIcon: false, + clearButton: true, + withCalendar: true, + size: 'large', + variant: 'outlined-bottom', + label: 'Label', +} diff --git a/packages/lsd-react/src/components/DatePicker/DatePicker.styles.ts b/packages/lsd-react/src/components/DatePicker/DatePicker.styles.ts new file mode 100644 index 0000000..21b3965 --- /dev/null +++ b/packages/lsd-react/src/components/DatePicker/DatePicker.styles.ts @@ -0,0 +1,31 @@ +import { css } from '@emotion/react' +import { datePickerClasses } from './DatePicker.classes' +import { dateFieldClasses } from '../DateField/DateField.classes' + +export const DatePickerStyles = css` + .${datePickerClasses.root} { + width: fit-content; + } + + .${datePickerClasses.calendar} { + border-top: none !important; + } + + .${datePickerClasses.large} { + .${dateFieldClasses.large} { + width: 318px; + } + } + + .${datePickerClasses.medium} { + .${dateFieldClasses.medium} { + width: 290px; + } + } + + .${datePickerClasses.small} { + .${dateFieldClasses.small} { + width: 262px; + } + } +` diff --git a/packages/lsd-react/src/components/DatePicker/DatePicker.tsx b/packages/lsd-react/src/components/DatePicker/DatePicker.tsx new file mode 100644 index 0000000..d8c3062 --- /dev/null +++ b/packages/lsd-react/src/components/DatePicker/DatePicker.tsx @@ -0,0 +1,117 @@ +import clsx from 'clsx' +import React, { useRef, useState } from 'react' +import { + dateToISODateString, + removeDateTimezoneOffset, +} from '../../utils/date.utils' +import { useInput } from '../../utils/useInput' +import { Calendar } from '../Calendar' +import { DateField } from '../DateField' +import { CalendarIcon } from '../Icons' +import { Portal } from '../PortalProvider/Portal' +import { datePickerClasses } from './DatePicker.classes' + +export type DatePickerProps = Omit< + React.HTMLAttributes, + 'onChange' | 'value' +> & + Pick, 'onChange'> & { + label?: React.ReactNode + error?: boolean + errorIcon?: boolean + clearButton?: boolean + disabled?: boolean + withCalendar?: boolean + supportingText?: string + value?: string + defaultValue?: string + placeholder?: string + size?: 'large' | 'medium' | 'small' + variant?: 'outlined' | 'outlined-bottom' + inputProps?: React.InputHTMLAttributes + } + +export const DatePicker: React.FC & { + classes: typeof datePickerClasses +} = ({ + label, + size = 'large', + value: valueProp, + onChange, + withCalendar = true, + variant = 'outlined-bottom', + ...props +}) => { + const ref = useRef(null) + const calendarIconRef = useRef(null) + const [openCalendar, setOpenCalendar] = useState(false) + const isControlled = typeof valueProp !== 'undefined' + + const input = useInput({ + value: valueProp, + defaultValue: '', + onChange, + getInput: () => + ref.current?.querySelector( + `input.${DateField.classes.input}`, + ) as HTMLInputElement, + }) + + const handleDateChange = (date: Date) => + input.setValue(dateToISODateString(removeDateTimezoneOffset(date))) + + const inputId = (props.id || 'date-picker') + '-input' + + return ( +
+ } + onIconClick={() => setOpenCalendar((prev) => !prev)} + // The DateField component is only controlled when the value prop is provided OR the calendar is open. + value={isControlled || openCalendar ? input.value : undefined} + onChange={input.onChange} + calendarIconRef={calendarIconRef} + {...props} + > + + {withCalendar && ( + handleDateChange(date)} + open={openCalendar} + onCalendarClickaway={(event) => { + // If the calendar icon was clicked, return and don't close the calendar here. + // Let the onIconClick above handle the closing. + if ( + calendarIconRef.current && + event?.composedPath().includes(calendarIconRef.current) + ) { + return + } + + setOpenCalendar(false) + }} + handleRef={ref} + value={input.value} + disabled={props.disabled} + className={datePickerClasses.calendar} + /> + )} + + +
+ ) +} + +DatePicker.classes = datePickerClasses diff --git a/packages/lsd-react/src/components/DatePicker/index.ts b/packages/lsd-react/src/components/DatePicker/index.ts new file mode 100644 index 0000000..192c395 --- /dev/null +++ b/packages/lsd-react/src/components/DatePicker/index.ts @@ -0,0 +1 @@ +export * from './DatePicker' diff --git a/packages/lsd-react/src/components/Icons/CalendarIcon/CalendarIcon.tsx b/packages/lsd-react/src/components/Icons/CalendarIcon/CalendarIcon.tsx new file mode 100644 index 0000000..3550d57 --- /dev/null +++ b/packages/lsd-react/src/components/Icons/CalendarIcon/CalendarIcon.tsx @@ -0,0 +1,24 @@ +import { LsdIcon } from '../LsdIcon' + +export const CalendarIcon = LsdIcon( + (props) => ( + + + + ), + { + filled: true, + }, +) diff --git a/packages/lsd-react/src/components/Icons/CalendarIcon/index.ts b/packages/lsd-react/src/components/Icons/CalendarIcon/index.ts new file mode 100644 index 0000000..e7b74eb --- /dev/null +++ b/packages/lsd-react/src/components/Icons/CalendarIcon/index.ts @@ -0,0 +1 @@ +export * from './CalendarIcon' diff --git a/packages/lsd-react/src/components/Icons/index.ts b/packages/lsd-react/src/components/Icons/index.ts index 06fe2af..b4c3312 100644 --- a/packages/lsd-react/src/components/Icons/index.ts +++ b/packages/lsd-react/src/components/Icons/index.ts @@ -19,4 +19,5 @@ export * from './SearchIcon' export * from './PickIcon' export * from './RadioButtonIcon' export * from './RadioButtonFilledIcon' +export * from './CalendarIcon' export * from './RemoveIcon' diff --git a/packages/lsd-react/src/index.ts b/packages/lsd-react/src/index.ts index eac4843..0a70eb2 100644 --- a/packages/lsd-react/src/index.ts +++ b/packages/lsd-react/src/index.ts @@ -34,3 +34,6 @@ export * from './components/NumberInput' export * from './components/Modal' export * from './components/ModalBody' export * from './components/ModalFooter' +export * from './components/DateField' +export * from './components/DatePicker' +export * from './components/Calendar' diff --git a/packages/lsd-react/src/utils/date.utils.ts b/packages/lsd-react/src/utils/date.utils.ts new file mode 100644 index 0000000..6b8f977 --- /dev/null +++ b/packages/lsd-react/src/utils/date.utils.ts @@ -0,0 +1,18 @@ +export const safeConvertDateToString = ( + value: string, + minDate: Date, + maxDate: Date, +) => { + const date = new Date(value ?? undefined) + const isValid = !Number.isNaN(+date) && date >= minDate && date <= maxDate + + return { + isValid, + date: isValid ? date : new Date(), + } +} +export const removeDateTimezoneOffset = (date: Date) => + new Date(+date - date.getTimezoneOffset() * 60 * 1000) + +export const dateToISODateString = (date: Date) => + date.toISOString().split('T')[0] diff --git a/packages/lsd-react/src/utils/useInput.ts b/packages/lsd-react/src/utils/useInput.ts index fa60220..2e6db79 100644 --- a/packages/lsd-react/src/utils/useInput.ts +++ b/packages/lsd-react/src/utils/useInput.ts @@ -12,6 +12,7 @@ export type InputProps = { defaultValue: T onChange?: InputOnChangeType ref?: React.RefObject + getInput?: () => HTMLInputElement } export const useInput = ( @@ -42,9 +43,12 @@ export const useInput = ( } const setter = (value: InputValueType) => { - if (!props.ref?.current) return + const element = + props?.ref?.current ?? + (typeof props.getInput === 'function' && props.getInput()) + + if (!element) return - const element = props.ref.current const event = new Event('input', { bubbles: true }) Object.getOwnPropertyDescriptor( diff --git a/yarn.lock b/yarn.lock index fd51bde..8a32f70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1344,6 +1344,13 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@datepicker-react/hooks@^2.8.4": + version "2.8.4" + resolved "https://registry.yarnpkg.com/@datepicker-react/hooks/-/hooks-2.8.4.tgz#6e07aa98bf21b90b7c88fb35919cca6eb08f2c31" + integrity sha512-qaYJKK5sOSdqcL/OnCtyv3/Q6fRRljfeAyl5ISTPgEO0CM5xZzkGmTx40+6wvqjH5lEZH4ysS95nPyLwZS2tlw== + dependencies: + date-fns "^2.14.0" + "@design-systems/utils@2.12.0": version "2.12.0" resolved "https://registry.npmjs.org/@design-systems/utils/-/utils-2.12.0.tgz" @@ -6789,6 +6796,11 @@ dargs@^7.0.0: resolved "https://registry.npmjs.org/dargs/-/dargs-7.0.0.tgz" integrity sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg== +date-fns@^2.14.0: + version "2.29.3" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8" + integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA== + dateformat@^3.0.0: version "3.0.3" resolved "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz"