From 8f30b051f9d7e0cd20ce5f47490759fc2cea3f53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Boukorras?= Date: Wed, 27 Apr 2022 08:36:03 +0200 Subject: [PATCH] feat: datepicker component --- .../src/components/calendar/calendar.scss | 116 +++++++++ .../core/src/components/calendar/calendar.ts | 6 + .../components/date-picker/date-picker.scss | 40 +++ .../src/components/date-picker/date-picker.ts | 4 + packages/core/src/components/index.scss | 6 + packages/core/src/components/index.ts | 2 + packages/core/src/components/input/input.ts | 10 +- .../components/calendar/calendar.stories.tsx | 53 ++++ .../src/components/calendar/calendar.tsx | 245 ++++++++++++++++++ .../date-picker/date-picker.stories.tsx | 33 +++ .../components/date-picker/date-picker.tsx | 105 ++++++++ packages/react/src/components/index.ts | 2 + yarn.lock | 6 +- 13 files changed, 624 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/components/calendar/calendar.scss create mode 100644 packages/core/src/components/calendar/calendar.ts create mode 100644 packages/core/src/components/date-picker/date-picker.scss create mode 100644 packages/core/src/components/date-picker/date-picker.ts create mode 100644 packages/react/src/components/calendar/calendar.stories.tsx create mode 100644 packages/react/src/components/calendar/calendar.tsx create mode 100644 packages/react/src/components/date-picker/date-picker.stories.tsx create mode 100644 packages/react/src/components/date-picker/date-picker.tsx diff --git a/packages/core/src/components/calendar/calendar.scss b/packages/core/src/components/calendar/calendar.scss new file mode 100644 index 000000000..8205fbe84 --- /dev/null +++ b/packages/core/src/components/calendar/calendar.scss @@ -0,0 +1,116 @@ +@use '../../helpers'; +@use '../../internal'; +@use '../../mixins'; + +@mixin Calendar() { + @include internal.IndicatorContainer(); + + @if not mixins.includes('Calendar') { + @include _Calendar(); + } +} + +@mixin _Calendar() { + $cldr-btn-size: helpers.space(6); + + .ods-calendar { + background-color: helpers.color('background-surface'); + border: 1px solid helpers.color('border-input'); + border-radius: helpers.border-radius('medium'); + padding: helpers.space(0.5); + width: helpers.space(42); + + &-nav { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: helpers.space(0.5); + width: 100%; + } + + &-nav-item { + border-radius: helpers.border-radius('full'); + height: $cldr-btn-size; + width: $cldr-btn-size; + } + + &-month { + align-items: center; + display: flex; + flex-grow: 1; + justify-content: center; + margin: 0; + } + + &-year { + box-sizing: content-box; + margin-left: 1ch; + width: 4ch; + } + + &-month, + &-year { + @include helpers.font('300-bold'); + } + + &-days, + &-dates { + display: flex; + flex-wrap: wrap; + list-style: none; + margin: 0; + padding: 0; + } + + &-days li, + &-dates li { + align-items: center; + display: flex; + justify-content: center; + width: $cldr-btn-size; + } + + &-days { + background-color: helpers.color('background-surface-alt'); + border-radius: helpers.border-radius('medium'); + color: helpers.color('content-secondary'); + height: helpers.space(4); + margin-bottom: helpers.space(0.5); + } + } + + .ods-button { + &.-date { + border-radius: helpers.border-radius('full'); + height: $cldr-btn-size; + width: $cldr-btn-size; + } + + &.-date:not(:disabled) { + color: helpers.color('content-main'); + } + + &.-date--selected:not(:disabled) { + background-color: helpers.color('background-action'); + color: helpers.color('content-inverse-main'); + } + + &.-date--today::before { + $cldr-indicator-size: helpers.space(0.75); + background-color: helpers.color('background-action'); + border-radius: helpers.border-radius('full'); + bottom: helpers.space(0.5); + + content: ''; + height: $cldr-indicator-size; + left: 50%; + position: absolute; + transform: translateX(-50%); + width: $cldr-indicator-size; + } + + &.-date--today.-date--selected::before { + background-color: helpers.color('background-inverse-action'); + } + } +} diff --git a/packages/core/src/components/calendar/calendar.ts b/packages/core/src/components/calendar/calendar.ts new file mode 100644 index 000000000..b40388e37 --- /dev/null +++ b/packages/core/src/components/calendar/calendar.ts @@ -0,0 +1,6 @@ +export interface CalendarProps { + canSelectFuture?: boolean; + canSelectPast?: boolean; + selectedDate?: string | null; + onDateSelect: (date: string) => void; +} diff --git a/packages/core/src/components/date-picker/date-picker.scss b/packages/core/src/components/date-picker/date-picker.scss new file mode 100644 index 000000000..3c11b93c6 --- /dev/null +++ b/packages/core/src/components/date-picker/date-picker.scss @@ -0,0 +1,40 @@ +@use '../../helpers'; +@use '../../internal'; +@use '../../mixins'; + +@mixin DatePicker() { + @include internal.IndicatorContainer(); + + @if not mixins.includes('DatePicker') { + @include _DatePicker(); + } +} + +@mixin _DatePicker() { + $stroke-size: helpers.space(0.25); + + .ods-date-picker-overlay { + inset: 0; + position: fixed; + } + + .ods-date-picker-container { + margin-bottom: 150vh; // To remove + position: relative; + } + + .ods-date-picker { + @include helpers.font('600-regular'); + position: relative; + width: helpers.space(24); + } + + .ods-date-picker-button { + height: helpers.space(6); + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + width: helpers.space(6); + } +} diff --git a/packages/core/src/components/date-picker/date-picker.ts b/packages/core/src/components/date-picker/date-picker.ts new file mode 100644 index 000000000..aa0812806 --- /dev/null +++ b/packages/core/src/components/date-picker/date-picker.ts @@ -0,0 +1,4 @@ +export interface DatePickerProps { + canSelectFuture?: boolean; + canSelectPast?: boolean; +} diff --git a/packages/core/src/components/index.scss b/packages/core/src/components/index.scss index 2d59eccc1..ccd2dacfc 100644 --- a/packages/core/src/components/index.scss +++ b/packages/core/src/components/index.scss @@ -38,6 +38,10 @@ @forward './tooltip/tooltip'; @use './validation/validation'; @forward './validation/validation'; +@use './date-picker/date-picker'; +@forward './date-picker/date-picker'; +@use './calendar/calendar'; +@forward './calendar/calendar'; @mixin components() { @include asterisk.Asterisk(); @@ -60,4 +64,6 @@ @include textarea.Textarea(); @include tooltip.Tooltip(); @include validation.Validation(); + @include date-picker.DatePicker(); + @include calendar.Calendar(); } diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index d7f3b6efd..7559a7e44 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -1,5 +1,7 @@ export * from './button/button'; +export * from './calendar/calendar'; export * from './checkbox/checkbox'; +export * from './date-picker/date-picker'; export * from './field/field'; export * from './form/form'; export * from './helper-text/helper-text'; diff --git a/packages/core/src/components/input/input.ts b/packages/core/src/components/input/input.ts index e4cf3bf0e..1c92c9840 100644 --- a/packages/core/src/components/input/input.ts +++ b/packages/core/src/components/input/input.ts @@ -1,5 +1,13 @@ export interface InputProps { disabled?: boolean; invalid?: boolean; - type?: 'text' | 'number' | 'email' | 'password' | 'tel' | 'url' | 'search'; + type?: + | 'text' + | 'number' + | 'email' + | 'password' + | 'tel' + | 'url' + | 'search' + | 'date'; } diff --git a/packages/react/src/components/calendar/calendar.stories.tsx b/packages/react/src/components/calendar/calendar.stories.tsx new file mode 100644 index 000000000..18e20a007 --- /dev/null +++ b/packages/react/src/components/calendar/calendar.stories.tsx @@ -0,0 +1,53 @@ +import { Calendar, CalendarProps } from '@onfido/castor-react'; +import React from 'react'; +import { Meta, Story } from '../../../../../docs'; + +export default { + title: 'React/Calendar', + component: Calendar, + argTypes: { + children: { description: 'Acts as a label for the ``.' }, + bordered: { table: { type: { summary: 'boolean' } } }, + disabled: { table: { type: { summary: 'boolean' } } }, + invalid: { table: { type: { summary: 'boolean' } } }, + }, + args: { + children: '', + bordered: false, + disabled: false, + invalid: false, + }, + parameters: { display: 'flex' }, +} as Meta; + +export const Playground: Story = { + render: () => ( + { + console.log('selected'); + }} + /> + ), +}; + +export const NoPastSelection: Story = { + render: () => ( + { + console.log('selected'); + }} + canSelectPast={false} + /> + ), +}; + +export const NoFutureSelection: Story = { + render: () => ( + { + console.log('selected'); + }} + canSelectFuture={false} + /> + ), +}; diff --git a/packages/react/src/components/calendar/calendar.tsx b/packages/react/src/components/calendar/calendar.tsx new file mode 100644 index 000000000..57d6e9105 --- /dev/null +++ b/packages/react/src/components/calendar/calendar.tsx @@ -0,0 +1,245 @@ +import { c, CalendarProps as BaseProps, classy } from '@onfido/castor'; +import React, { useEffect, useState } from 'react'; +import { Button } from '../button/button'; +import { Icon } from '../icon/icon'; +import { Input } from '../input/input'; + +const getShiftArray = (year: number, month: number) => { + const firstDay = new Date(year, month, 1).getDay(); + const shiftArray = []; + let shift = firstDay + 6; + + if (firstDay !== 0) { + shift = firstDay - 1; + } + + for (let index = 0; index < shift; index++) { + shiftArray.push(null); + } + + return shiftArray; +}; + +const getDaysToDisplay = ( + year: number, + month: number, + numberOfDays: number +) => { + const days = [...Array(numberOfDays + 1).keys()]; + days.shift(); + return [...getShiftArray(year, month), ...days]; +}; + +export const Calendar: React.FC = ({ + canSelectFuture = true, + canSelectPast = true, + onDateSelect, + selectedDate: selectedDateProps = null, +}) => { + const [displayedMonth, setDisplayedMonth] = useState( + new Date().getMonth() + ); + const [displayedYear, setDisplayedYear] = useState( + new Date().getFullYear() + ); + + const [selectedDate, setSelectedDate] = useState( + selectedDateProps ? selectedDateProps.split('/')[0] : '' + ); + const [selectedMonth, setSelectedMonth] = useState( + selectedDateProps ? selectedDateProps.split('/')[1] : '' + ); + const [selectedYear, setSelectedYear] = useState( + selectedDateProps ? selectedDateProps.split('/')[2] : '' + ); + + const [numberOfDisplayedDays, setNumberOfDisplayedDays] = useState(0); + + const previous = () => { + if (Number(displayedMonth) > 0) { + setDisplayedMonth(displayedMonth - 1); + } else { + setDisplayedMonth(11); + setDisplayedYear(displayedYear - 1); + } + }; + + const next = () => { + if (Number(displayedMonth) < 11) { + setDisplayedMonth(displayedMonth + 1); + } else { + setDisplayedMonth(0); + setDisplayedYear(displayedYear + 1); + } + }; + + const onDisplayedYearChange = ( + event: React.KeyboardEvent + ) => { + const value = (event.target as HTMLInputElement).value; + if (event.key !== ' ' && Number.isInteger(Number(event.key))) { + if (value.length === 0) { + setDisplayedYear(Number(event.key)); + } else { + if (value.length === 4 && value[0] === '0') { + setDisplayedYear(Number(value.substring(1) + event.key)); + } else { + setDisplayedYear(Number(event.key)); + } + } + } + + if (event.key === 'Backspace') { + setDisplayedYear(new Date().getFullYear()); + } + }; + + const selectDate = (date: number) => { + setSelectedDate(String(date).padStart(2, '0')); + setSelectedMonth(String(Number(displayedMonth) + 1).padStart(2, '0')); + setSelectedYear(String(displayedYear).padStart(4, '0')); + }; + + const isDateDisabled = (date: number) => { + const testedDate = new Date(displayedYear, displayedMonth, date); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + return ( + (!canSelectFuture ? testedDate > today : false) || + (!canSelectPast ? testedDate < today : false) + ); + }; + + const getDayClassName = (date: number) => { + let className = ''; + const today = new Date(); + const currentYear = today.getFullYear(); + const currentMonth = today.getMonth(); + const currentDate = today.getDate(); + + if ( + currentYear === displayedYear && + currentMonth === displayedMonth && + currentDate === date + ) { + className += '-date--today'; + } + + if ( + displayedMonth === Number(selectedMonth) - 1 && + displayedYear === Number(selectedYear) && + date === Number(selectedDate) + ) { + className = `${className} ${'-date--selected'}`; + } + + return className; + }; + + useEffect(() => { + setNumberOfDisplayedDays( + new Date(displayedYear, displayedMonth + 1, 0).getDate() + ); + }, [displayedMonth]); + + useEffect(() => { + if (selectedDate && selectedMonth && selectedYear) { + onDateSelect(`${selectedDate}/${selectedMonth}/${selectedYear}`); + } + }, [selectedDate, selectedMonth, selectedYear]); + + useEffect(() => { + setSelectedDate(selectedDateProps ? selectedDateProps.split('/')[0] : ''); + setSelectedMonth(selectedDateProps ? selectedDateProps.split('/')[1] : ''); + setSelectedYear(selectedDateProps ? selectedDateProps.split('/')[2] : ''); + if (selectedDateProps) { + setDisplayedMonth( + Number( + selectedDateProps ? Number(selectedDateProps.split('/')[1]) - 1 : '' + ) + ); + setDisplayedYear( + Number(selectedDateProps ? Number(selectedDateProps.split('/')[2]) : '') + ); + } + }, [selectedDateProps]); + + return ( +
+ + +
    +
  • M
  • +
  • T
  • +
  • W
  • +
  • Th
  • +
  • F
  • +
  • S
  • +
  • Su
  • +
+
    + {getDaysToDisplay( + displayedYear, + displayedMonth, + numberOfDisplayedDays + ).map((date, index) => ( +
  • + {date !== null && ( + + )} +
  • + ))} +
+
+ ); +}; + +export type CalendarProps = BaseProps & + Omit; diff --git a/packages/react/src/components/date-picker/date-picker.stories.tsx b/packages/react/src/components/date-picker/date-picker.stories.tsx new file mode 100644 index 000000000..2f7ba1728 --- /dev/null +++ b/packages/react/src/components/date-picker/date-picker.stories.tsx @@ -0,0 +1,33 @@ +import { DatePicker, DatePickerProps } from '@onfido/castor-react'; +import React from 'react'; +import { Meta, Story } from '../../../../../docs'; + +export default { + title: 'React/DatePicker', + component: DatePicker, + argTypes: { + children: { description: 'Acts as a label for the ``.' }, + bordered: { table: { type: { summary: 'boolean' } } }, + disabled: { table: { type: { summary: 'boolean' } } }, + invalid: { table: { type: { summary: 'boolean' } } }, + }, + args: { + children: '', + bordered: false, + disabled: false, + invalid: false, + }, + parameters: { display: 'flex' }, +} as Meta; + +export const Playground: Story = { + render: () => , +}; + +export const NoPastSelection: Story = { + render: () => , +}; + +export const NoFutureSelection: Story = { + render: () => , +}; diff --git a/packages/react/src/components/date-picker/date-picker.tsx b/packages/react/src/components/date-picker/date-picker.tsx new file mode 100644 index 000000000..8dc8ef12b --- /dev/null +++ b/packages/react/src/components/date-picker/date-picker.tsx @@ -0,0 +1,105 @@ +import { c, classy, DatePickerProps as BaseProps } from '@onfido/castor'; +import React, { + useState, + type ChangeEvent, + type FocusEvent, + type KeyboardEvent, +} from 'react'; +import { Button } from '../button/button'; +import { Calendar } from '../calendar/calendar'; +import { Icon } from '../icon/icon'; +import { Input } from '../input/input'; +import { Popover } from '../popover/popover'; + +export const DatePicker: React.FC = ({ + canSelectFuture = true, + canSelectPast = true, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedDate, setSelectedDate] = useState(null); + const [inputValue, setInputValue] = useState(''); + + const toggle = () => setIsOpen(!isOpen); + const close = () => setIsOpen(false); + const open = () => setIsOpen(true); + + const focus = (event: FocusEvent) => { + event.preventDefault(); + event.target.select(); + open(); + }; + + const validateInput = () => { + const regExp = + /^(0?[1-9]|[12][0-9]|3[01])[^a-zA-Z0-9](0?[1-9]|1[012])[^a-zA-Z0-9*]\d{4}$/; + if (regExp.test(inputValue)) { + const date = inputValue.replace(/[^a-zA-Z0-9*]/g, '/'); + setSelectedDate(date); + setInputValue(date); + } else { + setSelectedDate(null); + setInputValue(''); + } + }; + + const onDateSelect = (date: string) => { + if (date !== selectedDate) { + setSelectedDate(date); + setInputValue(date); + console.log({ date }); + } + }; + + const onChange = (event: ChangeEvent) => { + setInputValue(event.target.value); + }; + + const onKeyPress = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + validateInput(); + } + }; + + return ( +
+ {isOpen && ( +
+ )} +
+ + +
+ {isOpen && ( + + + + )} +
+ ); +}; + +export type DatePickerProps = BaseProps & + Omit; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 0a17e9d88..1b602ee15 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -1,6 +1,8 @@ export * from './asterisk/asterisk'; export * from './button/button'; +export * from './calendar/calendar'; export * from './checkbox/checkbox'; +export * from './date-picker/date-picker'; export * from './field-label/field-label'; export * from './field/field'; export * from './fieldset-legend/fieldset-legend'; diff --git a/yarn.lock b/yarn.lock index 03d6709f0..21175d5e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2090,9 +2090,9 @@ rimraf "^3.0.2" "@onfido/castor-icons@*": - version "2.8.2" - resolved "https://registry.yarnpkg.com/@onfido/castor-icons/-/castor-icons-2.8.2.tgz#3509a105e512dc13e7bc0f0e8f546b8322c2b459" - integrity sha512-K7KQFAgluPmDWaHpq72JyeeSw9Oand3nY1yRZJ4G7v36wd2mIOMz9uGKyzaQWDmq8bLdloTazHLE/caFm14gTA== + version "2.10.0" + resolved "https://registry.yarnpkg.com/@onfido/castor-icons/-/castor-icons-2.10.0.tgz#f98468b0ca466635e39b2e07a14cf6571d8875d2" + integrity sha512-R1rGudzI1EPl06rzLVtbsP0x8HlG2/irxWZedJJdgMctF4z+/Rf706uitkhA/WbW6hln72rD/dH+G6Fvqp8FXQ== "@onfido/castor-tokens@^1.0.0-beta.5": version "1.0.0-beta.5"