diff --git a/package-lock.json b/package-lock.json index 5b9183b..5b85c57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "react": "18.2.0", + "react-day-picker": "8.9.1", "react-dom": "18.2.0" }, "devDependencies": { @@ -38,6 +39,7 @@ "@typescript-eslint/parser": "5.59.6", "@vitest/coverage-c8": "0.31.0", "auto": "10.46.0", + "date-fns": "2.30.0", "eslint": "8.40.0", "eslint-plugin-react": "7.32.2", "eslint-plugin-storybook": "0.6.15", @@ -2598,7 +2600,6 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.5.tgz", "integrity": "sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.13.11" }, @@ -10527,6 +10528,21 @@ "node": ">=8" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/date-time": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/date-time/-/date-time-3.1.0.tgz", @@ -18893,6 +18909,19 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-day-picker": { + "version": "8.9.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.9.1.tgz", + "integrity": "sha512-W0SPApKIsYq+XCtfGeMYDoU0KbsG3wfkYtlw8l+vZp6KoBXGOlhzBUp4tNx1XiwiOZwhfdGOlj7NGSCKGSlg5Q==", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "date-fns": "^2.28.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-docgen": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-6.0.4.tgz", @@ -19303,8 +19332,7 @@ "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "node_modules/regenerator-transform": { "version": "0.15.2", diff --git a/package.json b/package.json index 262d3b1..baae8fd 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "homepage": "https://github.com/expenseapp-io/design-system#readme", "dependencies": { "react": "18.2.0", + "react-day-picker": "8.9.1", "react-dom": "18.2.0" }, "devDependencies": { @@ -67,6 +68,7 @@ "@typescript-eslint/parser": "5.59.6", "@vitest/coverage-c8": "0.31.0", "auto": "10.46.0", + "date-fns": "2.30.0", "eslint": "8.40.0", "eslint-plugin-react": "7.32.2", "eslint-plugin-storybook": "0.6.15", diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx new file mode 100644 index 0000000..433bb52 --- /dev/null +++ b/src/components/DatePicker.tsx @@ -0,0 +1,164 @@ +import React, { ChangeEvent, SyntheticEvent, useRef } from "react"; +import { CaptionProps, DayPicker, useNavigation } from "react-day-picker"; +import { CalendarIcon, CaretLeftIcon, CaretRightIcon } from "../icons"; + +interface CustomCaptionProps extends CaptionProps { + formatCaption: (date: Date) => string; +} + +function CustomCaption(props: CustomCaptionProps) { + const { goToMonth, nextMonth, previousMonth } = useNavigation(); + + return ( +
+ + + {props.formatCaption(props.displayMonth)} + + +
+ ); +} + +interface DatePickerProps { + selectValue: string; + datePickerValue: Date; + onChange: (newDate: Date) => void; + daysToSelect: { + value: string; + label: string; + }[]; + withNativePicker?: boolean; + formatCaption: (date: Date) => string; + cancelCopy?: string; +} + +function DatePicker(props: DatePickerProps) { + const { selectValue, datePickerValue, onChange, daysToSelect } = props; + const ref = useRef(null); + const inputRef = useRef(null); + + function openDatePicker() { + ref.current?.showModal(); + } + + function closeDatePicker() { + ref.current?.close(); + } + + function handleCloseDatePicker( + event: SyntheticEvent + ) { + event.stopPropagation(); + closeDatePicker(); + } + + function updateDate(newDate?: Date) { + if (newDate) { + onChange(newDate); + ref.current?.close(); + } + } + + function openNativeDatePicker() { + inputRef.current?.click(); + } + + function handleNativeDateChange(event: ChangeEvent) { + if (event.currentTarget.value) { + const newDate = new Date(event.currentTarget.value); + onChange(newDate); + } + } + + const footer = ( +
+ +
+ ); + + return ( + <> +
+ +
+ {/* TODO: this native input is not keyboard friendly on small screen sizes on desktop */} + {props.withNativePicker ? ( + + ) : ( + + )} +
+
+ + + ( + + ), + }} + footer={footer} + /> + + + ); +} + +export { DatePicker }; diff --git a/src/components/index.ts b/src/components/index.ts index 58745ba..3eb7152 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,3 +1,5 @@ /** * Export only component that could be used by consumer */ + +export { DatePicker } from "./DatePicker"; diff --git a/src/stories/components/DatePicker.stories.tsx b/src/stories/components/DatePicker.stories.tsx new file mode 100644 index 0000000..f715ebd --- /dev/null +++ b/src/stories/components/DatePicker.stories.tsx @@ -0,0 +1,73 @@ +import React, { useState } from "react"; + +import { Meta } from "@storybook/react"; +import { DatePicker } from "../../components/DatePicker"; +import { format, parse, sub } from "date-fns"; + +export default { + title: "Components/DatePicker", + component: DatePicker, +} as Meta; + +const valueKeyFormat = "yyyy-LL-dd"; +const valueFormat = "MMM d (eeee)"; + +const parseDate = (date: string): Date => { + return parse(date, valueKeyFormat, new Date()); +}; + +const formatDate = (date: Date) => { + return format(date, valueKeyFormat); +}; + +function generateLastWeekDates(startFrom: Date, selectedDate: Date) { + const days = []; + let currentDate = startFrom; + + for (let i = 0; i < 7; i++) { + const formattedDate = format(currentDate, valueFormat); + const customLabels = ["Today", "Yesterday"]; + + days.push({ + value: formatDate(currentDate), + label: customLabels[i] ? `${customLabels[i]}` : formattedDate, + }); + + currentDate = sub(currentDate, { + days: 1, + }); + } + + const doesSelectedDateExists = days.find( + (day) => day.value === formatDate(selectedDate) + ); + + if (!doesSelectedDateExists) { + days.unshift({ + value: formatDate(selectedDate), + label: format(selectedDate, valueFormat), + }); + } + + return days; +} + +export const Default = () => { + const [value, setValue] = useState(formatDate(new Date())); + + const daysToSelect = generateLastWeekDates(new Date(), parseDate(value)); + + return ( +
+ { + setValue(formatDate(newDate)); + }} + formatCaption={(date) => format(date, "MMMM, yyy")} + /> +
+ ); +}; diff --git a/src/styles/components/button.css b/src/styles/components/button.css index d6259d5..5ddd34b 100644 --- a/src/styles/components/button.css +++ b/src/styles/components/button.css @@ -24,10 +24,11 @@ display: inline-flex; gap: var(--space-4); + align-items: center; justify-content: center; margin: 0; - padding: var(--space-5); + padding: var(--space-4) var(--space-5); font: inherit; font-size: calc(var(--font-scale) * var(--font-base-size)); diff --git a/src/styles/components/date-picker.css b/src/styles/components/date-picker.css new file mode 100644 index 0000000..edbb745 --- /dev/null +++ b/src/styles/components/date-picker.css @@ -0,0 +1,166 @@ +@import "../abstracts/variables.css"; + +:root { + --calendar-backgorund: var(--white); + --calendar-border: var(--neutral-0); + --calendar-day-background-on-hover: var(--neutral-0); + --calendar-selected-day-background: var(--neutral-9); + --calendar-selected-day-foreground: var(--white); +} + +.dark-theme { + --calendar-backgorund: var(--neutral-8); + --calendar-border: var(--neutral-7); + --calendar-day-background-on-hover: var(--neutral-6); + --calendar-selected-day-background: var(--white); + --calendar-selected-day-foreground: var(--neutral-9); +} + +.date-picker { + padding: var(--space-7); + + background: var(--calendar-backgorund); + border: 1px solid var(--calendar-border); + border-radius: var(--space-5); + box-shadow: 0 10px 16px rgba(0 0 0 / 10%); +} + +.date-picker::backdrop { + background: rgb(191 191 191 / 60%); +} + +.dark-theme .date-picker::backdrop { + background: rgb(64 64 64 / 60%); +} + +.date-picker-select { + border-right: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.date-picker-select:focus-within { + border-right: 2px solid var(--focus-ring); +} + +.date-picker-container { + display: flex; +} + +.date-picker-button { + display: flex; + align-items: center; + justify-content: center; + + width: 54px; + height: 100%; + + color: var(--select-arrows); + + background: none; + border: 2px solid var(--select-border); + border-top-right-radius: var(--corner-5); + border-bottom-right-radius: var(--corner-5); +} + +.date-picker-button:focus { + border-color: var(--focus-ring); +} + +.date-picker-button-container { + position: relative; + display: flex; +} + +.date-picker-caption { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: var(--space-4); +} + +.date-picker-caption-month { + font-weight: bold; + color: var(--text-primary); +} + +.date-picker-footer { + display: flex; + justify-content: flex-end; + margin-top: var(--space-4); +} + +/* stylelint-disable selector-class-pattern */ + +.rdp table { + border-spacing: 0; + border-collapse: collapse; +} + +.rdp-cell { + padding: 0; +} + +.rdp-day { + display: inline-flex; + align-items: center; + justify-content: center; + + width: 40px; + height: 40px; + + font: inherit; + color: var(--text-primary); + + background: none; + border: none; + border-radius: var(--space-5); +} + +.rdp-head_cell { + width: 40px; + height: 40px; + + font-weight: bold; + color: var(--text-primary); + vertical-align: middle; +} + +.rdp-day_outside { + color: var(--text-disabled); +} + +.rdp-day:not(.rdp-day_selected):hover, +.rdp-day:not(.rdp-day_selected):focus { + background: var(--calendar-day-background-on-hover); +} + +.rdp-day_selected { + color: var(--calendar-selected-day-foreground); + background: var(--calendar-selected-day-background); +} + +/* stylelint-enable selector-class-pattern */ + +/* Mobile specific */ + +.date-picker-mobile-button-container { + position: relative; + overflow: hidden; +} + +.date-picker-mobile-input { + position: absolute; + top: 0; + left: 0; + + width: 100%; + height: 100%; + + opacity: 0; +} + +.date-picker-mobile-input::-webkit-calendar-picker-indicator { + width: 100%; + height: 100%; +} diff --git a/src/styles/main.css b/src/styles/main.css index ca6628b..98d258f 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -10,3 +10,4 @@ @import "./components/select.css"; @import "./components/dialog.css"; +@import "./components/date-picker.css";