From 1866ca8c42b1e6c851511b9e12e352c50466297e Mon Sep 17 00:00:00 2001 From: marcelines Date: Tue, 13 Jun 2023 11:33:14 +0100 Subject: [PATCH] [website][status-web] Calendar (#410) * feat: adds calendar component to components package * feat: add datepicker component to use in insights * feat: clean calendar code and fix spacing --- .../src/components/datepicker/datepicker.tsx | 45 +++ .../src/pages/insights/epics/index.tsx | 8 + packages/components/package.json | 2 + packages/components/src/calendar/calendar.css | 354 ++++++++++++++++++ .../src/calendar/calendar.stories.tsx | 86 +++++ packages/components/src/calendar/calendar.tsx | 34 ++ .../src/calendar/components/caption.tsx | 44 +++ .../src/calendar/components/row.tsx | 54 +++ packages/components/src/calendar/index.tsx | 1 + packages/components/src/index.tsx | 2 +- yarn.lock | 19 + 11 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 apps/website/src/components/datepicker/datepicker.tsx create mode 100644 packages/components/src/calendar/calendar.css create mode 100644 packages/components/src/calendar/calendar.stories.tsx create mode 100644 packages/components/src/calendar/calendar.tsx create mode 100644 packages/components/src/calendar/components/caption.tsx create mode 100644 packages/components/src/calendar/components/row.tsx create mode 100644 packages/components/src/calendar/index.tsx diff --git a/apps/website/src/components/datepicker/datepicker.tsx b/apps/website/src/components/datepicker/datepicker.tsx new file mode 100644 index 00000000..c2a0d54f --- /dev/null +++ b/apps/website/src/components/datepicker/datepicker.tsx @@ -0,0 +1,45 @@ +import { Calendar } from '@status-im/components/src/calendar/calendar' +import { Popover } from '@status-im/components/src/popover' +import { EditIcon } from '@status-im/icons' + +import { formatDate } from '../chart/utils/format-time' + +import type { DateRange } from '@status-im/components/src/calendar/calendar' + +type Props = { + selected?: DateRange + onSelect: (selected?: DateRange) => void +} + +const DatePicker = (props: Props) => { + const { selected, onSelect } = props + + return ( +
+ + + + + + +
+ ) +} + +export { DatePicker } diff --git a/apps/website/src/pages/insights/epics/index.tsx b/apps/website/src/pages/insights/epics/index.tsx index ac9cc533..c6aab954 100644 --- a/apps/website/src/pages/insights/epics/index.tsx +++ b/apps/website/src/pages/insights/epics/index.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react' + import { IconButton, Shadow, Tag, Text } from '@status-im/components' import { DoneIcon, @@ -7,9 +9,11 @@ import { SortIcon, } from '@status-im/icons' +import { DatePicker } from '@/components/datepicker/datepicker' import { EpicOverview } from '@/components/epic-overview' import { InsightsLayout } from '@/layouts/insights-layout' +import type { DateRange } from '@status-im/components/src/calendar/calendar' import type { Page } from 'next' const epics = [ @@ -27,6 +31,8 @@ const epics = [ ] const EpicsPage: Page = () => { + const [selectedDates, setSelectedDates] = useState() + return (
@@ -53,6 +59,8 @@ const EpicsPage: Page = () => { ))}
+ + ) } diff --git a/packages/components/package.json b/packages/components/package.json index bf90cb04..307e2267 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -46,8 +46,10 @@ "@tamagui/react-native-media-driver": "1.11.1", "@tamagui/shorthands": "1.11.1", "@tamagui/theme-base": "1.11.1", + "date-fns": "^2.30.0", "expo-blur": "^12.2.2", "expo-linear-gradient": "^12.1.2", + "react-day-picker": "^8.7.1", "tamagui": "1.11.1", "zustand": "^4.3.7" }, diff --git a/packages/components/src/calendar/calendar.css b/packages/components/src/calendar/calendar.css new file mode 100644 index 00000000..3628f4e0 --- /dev/null +++ b/packages/components/src/calendar/calendar.css @@ -0,0 +1,354 @@ +.rdp { + --rdp-cell-size: 32px; + --rdp-caption-font-size: 15px; + --rdp-accent-color: #2a4af5; + --rdp-background-color: #e7edff; + --rdp-background-color-selected-secondary: #f5f6f8; + --rdp-hover-color: #f5f6f8; + --rdp-hover-color-darker: #f0f2f5; + --rdp-accent-color-dark: #223bc4; + --rdp-outline: 2px solid var(--rdp-accent-color); /* Outline border for focused elements */ + --rdp-outline-selected: 3px solid var(--rdp-accent-color); /* Outline border for focused _and_ selected elements */ + --rdp-text-color: #09101c; + color: var(--rdp-text-color); + font-family: Inter, sans-serif; + font-weight: 500; + user-select: none; +} + +/* Hide elements for devices that are not screen readers */ +.rdp-vhidden { + box-sizing: border-box; + padding: 0; + margin: 0; + background: transparent; + border: 0; + -moz-appearance: none; + -webkit-appearance: none; + appearance: none; + position: absolute !important; + top: 0; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + overflow: hidden !important; + clip: rect(1px, 1px, 1px, 1px) !important; + border: 0 !important; +} + +/* Buttons */ +.rdp-button_reset { + appearance: none; + position: relative; + margin: 0; + padding: 0; + cursor: default; + color: inherit; + background: none; + font: inherit; + + -moz-appearance: none; + -webkit-appearance: none; +} + +.rdp-button_reset:focus-visible { + /* Make sure to reset outline only when :focus-visible is supported */ + outline: none; +} + +.rdp-button { + border: 2px solid transparent; + font-size: 0.8125rem; +} + +.rdp-button[disabled]:not(.rdp-day_selected) { + opacity: 0.25; +} + +.rdp-button:not([disabled]) { + cursor: pointer; +} + +.rdp-button:focus-visible:not([disabled]) { + color: inherit; + background-color: var(--rdp-background-color); + border: var(--rdp-outline); +} + +.rdp-button:hover:not([disabled]):not(.rdp-day_selected) { + background-color: var(--rdp-hover-color); +} + +.rdp-months { + display: flex; +} + +.rdp-month { + margin: 0 1em; +} + +.rdp-month:first-child { + margin-left: 0; +} + +.rdp-month:last-child { + margin-right: 0; +} + +.rdp-table { + margin: 0; + max-width: calc(var(--rdp-cell-size) * 7); + border-collapse: separate; + border-spacing: 0 2px; + padding: 0 0.75rem 0.625rem 0.75rem; +} + +.rdp-with_weeknumber .rdp-table { + max-width: calc(var(--rdp-cell-size) * 8); + border-collapse: collapse; +} + +.rdp-caption { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0; + text-align: left; +} + +.rdp-multiple_months .rdp-caption { + position: relative; + display: block; + text-align: center; +} + +.rdp-caption_dropdowns { + position: relative; + display: inline-flex; +} + +.rdp-caption_label { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + margin: 0; + white-space: nowrap; + color: currentColor; + font-family: inherit; + font-size: var(--rdp-caption-font-size); + font-weight: bold; +} + +.rdp-nav { + white-space: nowrap; + padding: 0.375rem 0.375rem 0 0.75rem; +} + +.rdp-multiple_months .rdp-caption_start .rdp-nav { + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); +} + +.rdp-multiple_months .rdp-caption_end .rdp-nav { + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); +} + +.rdp-nav_button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0.25em; + border-radius: 10px; +} + +/* ---------- */ +/* Dropdowns */ +/* ---------- */ + +.rdp-dropdown_year, +.rdp-dropdown_month { + position: relative; + display: inline-flex; + align-items: center; +} + +.rdp-dropdown { + appearance: none; + position: absolute; + z-index: 2; + top: 0; + bottom: 0; + left: 0; + width: 100%; + margin: 0; + padding: 0; + cursor: inherit; + opacity: 0; + border: none; + background-color: transparent; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +.rdp-dropdown[disabled] { + opacity: unset; + color: unset; +} + +.rdp-dropdown:focus-visible:not([disabled]) + .rdp-caption_label { + background-color: var(--rdp-background-color); + border: var(--rdp-outline); + border-radius: 6px; +} + +.rdp-dropdown_icon { + margin: 0 0 0 5px; +} + +.rdp-head { + border: 0; +} + +.rdp-head_row, +.rdp-row { + height: 100%; + padding-bottom: 2px; +} + +.rdp-head_cell { + vertical-align: middle; + font-size: 0.8125em; + font-weight: 500; + + text-align: center; + height: var(--rdp-cell-size); + padding: 0; + + line-height: 140%; + color: #647084; + letter-spacing: -0.003em; +} + +.rdp-tbody { + border: 0; +} + +.rdp-tfoot { + margin: 0.5em; +} + +.rdp-cell { + width: var(--rdp-cell-size); + height: var(--rdp-cell-size); + text-align: center; + padding: 0; +} + +.rdp-cell_selected_start { + border-top-left-radius: 10px; + border-bottom-left-radius: 10px; + background-color: var(--rdp-background-color-selected-secondary); +} + +.rdp-cell_selected_end { + border-top-right-radius: 10px; + border-bottom-right-radius: 10px; + background-color: var(--rdp-background-color-selected-secondary); +} + +.rdp-cell_selected_range { + background-color: var(--rdp-background-color-selected-secondary); +} + +.rdp-weeknumber { + font-size: 0.75em; +} + +.rdp-weeknumber, +.rdp-day { + display: flex; + overflow: hidden; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: var(--rdp-cell-size); + max-width: var(--rdp-cell-size); + height: var(--rdp-cell-size); + margin: 0; + border: 2px solid transparent; + border-radius: 10px; +} + +.rdp-day_today:not(.rdp-day_outside) { + position: relative; +} +.rdp-day_today:not(.rdp-day_outside)::after { + content: ''; + position: absolute; + bottom: 3px; + left: 50%; + width: 4px; + height: 2px; + transform: translateX(-50%); + + border-radius: 10px; + background-color: var(--rdp-accent-color); +} + +.rdp-day_selected { + opacity: 1; + background-color: var(--rdp-accent-color); + color: var(--rdp-background-color); + transition: all 150ms ease-in-out; +} + +.rdp-day_selected:focus-visible, +.rdp-day_selected:hover { + opacity: 1; + background-color: var(--rdp-accent-color-dark); + border-radius: 10px; +} + +.rdp-day_outside { + opacity: 0.3; +} + +.rdp-day_selected:focus-visible { + /* Since the background is the same use again the outline */ + outline: var(--rdp-outline); + outline-offset: 2px; + z-index: 1; +} + +.rdp[dir='rtl'] .rdp-day_range_start:not(.rdp-day_range_end) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.rdp[dir='rtl'] .rdp-day_range_end:not(.rdp-day_range_start) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.rdp-day_range_end.rdp-day_range_start { + border-radius: 10px; +} + +.rdp-day_range_middle { + color: var(--rdp-text-color); + background-color: var(--rdp-background-color-selected-secondary); +} + +.rdp-day_range_middle:hover { + color: var(--rdp-text-color); + background-color: var(--rdp-hover-color-darker); +} diff --git a/packages/components/src/calendar/calendar.stories.tsx b/packages/components/src/calendar/calendar.stories.tsx new file mode 100644 index 00000000..7066c2ed --- /dev/null +++ b/packages/components/src/calendar/calendar.stories.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react' + +import { Stack } from '@tamagui/core' +import { format } from 'date-fns' + +import { Text } from '../text' +import { Calendar } from './calendar' + +import type { Meta, StoryObj } from '@storybook/react' +import type { DateRange } from 'react-day-picker' + +// More on how to set up stories at: https://storybook.js.org/docs/7.0/react/writing-stories/introduction +const meta: Meta = { + component: Calendar, + args: {}, + argTypes: { + disabled: { + defaultValue: false, + }, + }, +} + +// More on writing stories with args: https://storybook.js.org/docs/7.0/react/writing-stories/args +type Story = StoryObj + +const SingleCalendarWithHooks = () => { + const [selected, setSelected] = useState() + + return ( + <> + + + + Selected date: {selected && format(selected, 'do MMM yyyy')} + + + + ) +} + +const MultipleCalendarWithHooks = () => { + const [selected, setSelected] = useState() + return ( + <> + + + + Selected dates:{' '} + {selected?.map(date => format(date, 'do MMM yyyy')).join(', ')} + + + + ) +} + +const RangeCalendarWithHooks = () => { + const [selected, setSelected] = useState() + + return ( + <> + + + + Start date: {selected?.from && format(selected.from, 'do MMM yyyy')} + + + End Date: {selected?.to && format(selected.to, 'do MMM yyyy')} + + + + ) +} + +export const SinglePicker: Story = { + render: () => , +} + +export const MultiplePicker: Story = { + render: () => , +} + +export const RangePicker: Story = { + render: () => , +} + +export default meta diff --git a/packages/components/src/calendar/calendar.tsx b/packages/components/src/calendar/calendar.tsx new file mode 100644 index 00000000..ffa186b5 --- /dev/null +++ b/packages/components/src/calendar/calendar.tsx @@ -0,0 +1,34 @@ +import './calendar.css' + +import { DayPicker } from 'react-day-picker' + +import { Shadow } from '../shadow' +import { CustomCaption } from './components/caption' +import { CustomRow } from './components/row' + +import type { DateRange, DayPickerProps } from 'react-day-picker' + +type Props = DayPickerProps + +const Calendar = (props: Props): JSX.Element => { + return ( + + + + ) +} + +export { Calendar } +export type { Props as CalendarProps, DateRange } diff --git a/packages/components/src/calendar/components/caption.tsx b/packages/components/src/calendar/components/caption.tsx new file mode 100644 index 00000000..3a9d27d9 --- /dev/null +++ b/packages/components/src/calendar/components/caption.tsx @@ -0,0 +1,44 @@ +import { ChevronLeftIcon, ChevronRightIcon } from '@status-im/icons' +import { Stack } from '@tamagui/core' +import { format } from 'date-fns' +import { useNavigation } from 'react-day-picker' + +import { IconButton } from '../../icon-button' + +import type { CaptionProps } from 'react-day-picker' + +const CustomCaption = (props: CaptionProps): JSX.Element => { + const { goToMonth, nextMonth, previousMonth } = useNavigation() + return ( +
+
+
+ {format(props.displayMonth, 'MMM yyy')} +
+ + + } + disabled={!previousMonth} + onPress={() => previousMonth && goToMonth(previousMonth)} + /> + } + disabled={!nextMonth} + onPress={() => nextMonth && goToMonth(nextMonth)} + /> + +
+
+ ) +} + +export { CustomCaption } diff --git a/packages/components/src/calendar/components/row.tsx b/packages/components/src/calendar/components/row.tsx new file mode 100644 index 00000000..149a970d --- /dev/null +++ b/packages/components/src/calendar/components/row.tsx @@ -0,0 +1,54 @@ +import { getUnixTime, isEqual } from 'date-fns' +import { Day, useDayPicker } from 'react-day-picker' + +import type { DateRange, RowProps } from 'react-day-picker' + +const CustomRow = (props: RowProps): JSX.Element => { + const { styles, classNames, selected } = useDayPicker() + + const castSelected = selected as DateRange + + return ( + + {props.dates.map(date => { + const isSelectedStartDate = + castSelected?.from && isEqual(date, castSelected.from) + const isSelectedEndDate = + castSelected?.to && isEqual(date, castSelected.to) + + const cellClassNames = () => { + if (isSelectedStartDate) { + return classNames.cell + ' ' + 'rdp-cell_selected_start' + } + if (isSelectedEndDate) { + return classNames.cell + ' ' + 'rdp-cell_selected_end' + } + + if ( + castSelected?.from && + castSelected?.to && + date > castSelected.from && + date < castSelected.to + ) { + return classNames.cell + ' ' + 'rdp-cell_selected_range' + } + + return classNames.cell + } + + return ( + + + + ) + })} + + ) +} + +export { CustomRow } diff --git a/packages/components/src/calendar/index.tsx b/packages/components/src/calendar/index.tsx new file mode 100644 index 00000000..e8afcc58 --- /dev/null +++ b/packages/components/src/calendar/index.tsx @@ -0,0 +1 @@ +export { Calendar } from './calendar' diff --git a/packages/components/src/index.tsx b/packages/components/src/index.tsx index d47ecf59..ef945ee6 100644 --- a/packages/components/src/index.tsx +++ b/packages/components/src/index.tsx @@ -1,6 +1,7 @@ export * from './anchor-actions' export * from './avatar' export * from './button' +export * from './calendar' export * from './community' export * from './composer' export * from './context-tag' @@ -21,6 +22,5 @@ export * from './tag' export * from './text' export * from './toast' export * from './user-list' - // eslint-disable-next-line simple-import-sort/exports export { config } from './tamagui.config' diff --git a/yarn.lock b/yarn.lock index 181f973a..ddfd3e9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1818,6 +1818,13 @@ dependencies: regenerator-runtime "^0.13.11" +"@babel/runtime@^7.21.0": + version "7.21.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.5.tgz#8492dddda9644ae3bda3b45eabe87382caee7200" + integrity sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q== + dependencies: + regenerator-runtime "^0.13.11" + "@babel/template@7.18.10", "@babel/template@^7.18.10": version "7.18.10" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71" @@ -10246,6 +10253,13 @@ datastore-core@^8.0.1: it-take "^1.0.1" uint8arrays "^3.0.0" +date-fns@^2.30.0: + version "2.30.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0" + integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw== + dependencies: + "@babel/runtime" "^7.21.0" + dayjs@^1.8.15: version "1.11.7" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.7.tgz#4b296922642f70999544d1144a2c25730fce63e2" @@ -16867,6 +16881,11 @@ react-colorful@^5.1.2: resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b" integrity sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw== +react-day-picker@^8.7.1: + version "8.7.1" + resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-8.7.1.tgz#b8388c2afa69d4a6da4c5fde5323fae884acfe2f" + integrity sha512-Gv426AW8b151CZfh3aP5RUGztLwHB/EyJgWZ5iMgtzbFBkjHfG6Y66CIQFMWGLnYjsQ9DYSJRmJ5S0Pg5HWKjA== + react-devtools-core@4.24.0: version "4.24.0" resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-4.24.0.tgz#7daa196bdc64f3626b3f54f2ff2b96f7c4fdf017"