From 18279f25032973643c6a54472e04a81c92f8de99 Mon Sep 17 00:00:00 2001 From: Denis Yakshov Date: Tue, 9 Jul 2024 18:24:16 +0300 Subject: [PATCH] feat(react,DateInput): add new component --- .../DateInput/DateInput.registry.tsx | 129 ++++++ .../DateInput/DateInput.stories.tsx | 34 ++ .../src/components/DateInput/DateInput.tsx | 426 ++++++++++++++++++ .../components/DateInput/DateInput.types.ts | 45 ++ .../components/DateInput/DateInput.utils.tsx | 74 +++ .../react/src/components/DateInput/index.ts | 1 + .../theme/lib/components/_date-input.scss | 20 + packages/theme/lib/components/_index.scss | 2 + 8 files changed, 731 insertions(+) create mode 100644 packages/react/src/components/DateInput/DateInput.registry.tsx create mode 100644 packages/react/src/components/DateInput/DateInput.stories.tsx create mode 100644 packages/react/src/components/DateInput/DateInput.tsx create mode 100644 packages/react/src/components/DateInput/DateInput.types.ts create mode 100644 packages/react/src/components/DateInput/DateInput.utils.tsx create mode 100644 packages/react/src/components/DateInput/index.ts create mode 100644 packages/theme/lib/components/_date-input.scss diff --git a/packages/react/src/components/DateInput/DateInput.registry.tsx b/packages/react/src/components/DateInput/DateInput.registry.tsx new file mode 100644 index 000000000..288d7a5d3 --- /dev/null +++ b/packages/react/src/components/DateInput/DateInput.registry.tsx @@ -0,0 +1,129 @@ +import { ReactNode } from 'react'; + +import { DateInputField, DateInputFormatter } from './DateInput.types'; + +import { addWithLoop, getGenericRangeValues, getZeroPaddedPlaceholder, isNumber } from './DateInput.utils'; + +export const DateInputRegistry = new Map(); + +export class DateInputYearFormatter implements DateInputFormatter { + field = 'year' as const; + + getDefaultValue = () => new Date().getFullYear().toString(); + + getPlaceholder = (value: string, selected: boolean, context: Record): ReactNode => { + return getZeroPaddedPlaceholder(this, value, selected, context, 4, 'Y'); + }; + + getValues = (value: string, key: string) => { + if (!isNumber(key)) { + return []; + } + + if (value.length === 4) { + return [`${key}`, `${key}x`]; + } + + const requested = `${value}${key}`; + + if (requested.length === 4) { + return [requested]; + } + + return [requested, `${requested}x`]; + }; + + getPrev = (value: string, step: number) => { + return addWithLoop(+value, -step, 0, 9999).toString(); + }; + + getNext = (value: string, step: number) => { + return addWithLoop(+value, step, 0, 9999).toString(); + }; +} + +export class DateInputMonthFormatter implements DateInputFormatter { + field = 'month' as const; + + getDefaultValue = () => '0'; + + getPlaceholder = (value: string, selected: boolean, context: Record): ReactNode => { + return getZeroPaddedPlaceholder(this, value, selected, context, 2, 'M'); + }; + + getValues = getGenericRangeValues(1, 12); + + getPrev = (value: string, step: number) => { + return addWithLoop(+value, -step, 1, 12).toString(); + }; + + getNext = (value: string, step: number) => { + return addWithLoop(+value, step, 1, 12).toString(); + }; +} + +export class DateInputDateFormatter implements DateInputFormatter { + field = 'date' as const; + + getDefaultValue = () => '0'; + + getPlaceholder = (value: string, selected: boolean, context: Record): ReactNode => { + return getZeroPaddedPlaceholder(this, value, selected, context, 2, 'D'); + }; + + getValues = getGenericRangeValues(1, 31); + + getPrev = (value: string, step: number) => { + return addWithLoop(+value, -step, 1, 31).toString(); + }; + + getNext = (value: string, step: number) => { + return addWithLoop(+value, step, 1, 31).toString(); + }; +} + +export class DateInputHoursFormatter implements DateInputFormatter { + field = 'hours' as const; + + getDefaultValue = () => '0'; + + getPlaceholder = (value: string, selected: boolean, context: Record): ReactNode => { + return getZeroPaddedPlaceholder(this, value, selected, context, 2, 'h'); + }; + + getValues = getGenericRangeValues(0, 23); + + getPrev = (value: string, step: number) => { + return addWithLoop(+value, -step, 0, 23).toString(); + }; + + getNext = (value: string, step: number) => { + return addWithLoop(+value, step, 0, 23).toString(); + }; +} + +export class DateInputMinutesFormatter implements DateInputFormatter { + field = 'minutes' as const; + + getDefaultValue = () => '0'; + + getPlaceholder = (value: string, selected: boolean, context: Record): ReactNode => { + return getZeroPaddedPlaceholder(this, value, selected, context, 2, 'm'); + }; + + getValues = getGenericRangeValues(0, 59); + + getPrev = (value: string, step: number) => { + return addWithLoop(+value, -step, 0, 59).toString(); + }; + + getNext = (value: string, step: number) => { + return addWithLoop(+value, step, 0, 59).toString(); + }; +} + +DateInputRegistry.set('YYYY', new DateInputYearFormatter()); +DateInputRegistry.set('MM', new DateInputMonthFormatter()); +DateInputRegistry.set('dd', new DateInputDateFormatter()); +DateInputRegistry.set('HH', new DateInputHoursFormatter()); +DateInputRegistry.set('mm', new DateInputMinutesFormatter()); diff --git a/packages/react/src/components/DateInput/DateInput.stories.tsx b/packages/react/src/components/DateInput/DateInput.stories.tsx new file mode 100644 index 000000000..afa80a953 --- /dev/null +++ b/packages/react/src/components/DateInput/DateInput.stories.tsx @@ -0,0 +1,34 @@ +import { useState } from 'react'; + +import { Meta, StoryObj } from '@storybook/react'; + +import { DateInput } from '.'; + +const meta: Meta = { + tags: ['autodocs'], + component: DateInput, + parameters: { + references: ['DateInput'], + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Demo: Story = { + render: function Render() { + const [value, setValue] = useState(null); + + const onChange = (value: Date | null) => { + setValue(value); + }; + + return ( + <> + +
{value ? value.toISOString() : 'null'}
+ + ); + }, +}; diff --git a/packages/react/src/components/DateInput/DateInput.tsx b/packages/react/src/components/DateInput/DateInput.tsx new file mode 100644 index 000000000..2534421bf --- /dev/null +++ b/packages/react/src/components/DateInput/DateInput.tsx @@ -0,0 +1,426 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { DateInputField, DateInputProps } from './DateInput.types'; + +import { DateInputRegistry } from './DateInput.registry'; + +import { useDateAdapterContext } from '../DateAdapter'; + +const getDate = ( + year: number, + month: number, + date: number, + hours: number, + minutes: number, + seconds: number, + milliseconds: number +) => { + return new Date( + Date.parse( + `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${date.toString().padStart(2, '0')}T${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}` + ) + ); +}; + +const dateToValue = (date: Date): Record => { + return { + year: date.getFullYear().toString(), + month: (date.getMonth() + 1).toString(), + date: date.getDate().toString(), + hours: date.getHours().toString(), + minutes: date.getMinutes().toString(), + seconds: date.getSeconds().toString(), + milliseconds: date.getMilliseconds().toString(), + }; +}; + +const valueToDate = (value: Record): Date => { + return new Date( + +value.year, + Math.max(+value.month - 1, 0), + +value.date, + +value.hours, + +value.minutes, + +value.seconds, + +value.milliseconds + ); +}; + +const MIN_DATE = getDate(1, 1, 1, 0, 0, 0, 0); +const MAX_DATE = getDate(9999, 12, 31, 23, 59, 59, 999); + +const EMPTY = { + year: '', + month: '', + date: '', + hours: '', + minutes: '', + seconds: '', + milliseconds: '', +}; + +export const DateInput = ({ + minDate = MIN_DATE, + maxDate = MAX_DATE, + format = 'YYYY.MM.dd HH:mm', + onChange, +}: DateInputProps) => { + const ref = useRef(null); + const { adapter } = useDateAdapterContext(); + + const tokens = useMemo(() => { + const regexp = new RegExp(`(${Array.from(DateInputRegistry.keys()).join('|')})`); + return format.split(regexp).filter(Boolean) as Array; + }, [format]); + + const [value, setValue] = useState>(EMPTY); + const [currentToken, setCurrentToken] = useState(null); + + const { empty, filled } = useMemo(() => { + const keys = tokens.filter((token) => !!DateInputRegistry.get(token)); + const formatters = keys.map((key) => DateInputRegistry.get(key)!); + + const empty = formatters.every((formatter) => !value[formatter.field]); + const filled = formatters.every((formatter) => !!value[formatter.field]); + + return { empty, filled }; + }, [value, tokens]); + + useEffect(() => { + if (onChange) { + if (filled) { + const date = valueToDate(value); + onChange(date); + } else if (empty) { + onChange(null); + } + } + }, [value, empty, filled]); + + const getAnchorElement = (target?: HTMLElement): HTMLElement | null => { + const selection = window.getSelection(); + + if (ref.current && selection) { + let element = target || (selection.anchorNode as HTMLElement | null | undefined); + + if (!ref.current.contains(element || null)) { + return null; + } + + // TEXT_NODE + if (element?.nodeType === 3) { + element = element.parentElement; + } + + element = element?.closest('span[data-node]'); + + if (element) { + if (!element.dataset.token) { + if (selection.anchorOffset < (selection.anchorNode?.textContent?.length || 0) / 2) { + element = element.previousElementSibling as HTMLElement | null; + } else { + element = element.nextElementSibling as HTMLElement | null; + } + } + + if (element) { + return element; + } + } + } + + return null; + }; + + const select = (element: HTMLElement) => { + requestAnimationFrame(() => { + const selection = window.getSelection(); + + if (ref.current && selection) { + if (element) { + setCurrentToken(element.dataset?.token || null); + + setTimeout(() => { + const range = document.createRange(); + range.selectNodeContents(element); + + selection.removeAllRanges(); + selection.addRange(range); + }, 1); + } + } + }); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Tab') { + return; + } + + e.preventDefault(); + + if (!ref.current) { + return; + } + + const selection = window.getSelection(); + + if (ref.current === selection?.anchorNode) { + switch (e.key) { + case 'ArrowLeft': { + const elements = ref.current.querySelectorAll('span[data-token]'); + + if (elements.length) { + select(elements[0] as HTMLElement); + } + + return; + } + case 'ArrowRight': { + const elements = ref.current.querySelectorAll('span[data-token]'); + + if (elements.length) { + select(elements[elements.length - 1] as HTMLElement); + } + + return; + } + case 'ArrowUp': + case '+': { + if (filled) { + setValue( + dateToValue(new Date(Math.min(maxDate.getTime(), adapter!.addDays(valueToDate(value), 1).getTime()))) + ); + } else { + setValue(dateToValue(minDate)); + } + + return; + } + case 'PageUp': { + if (filled) { + setValue( + dateToValue(new Date(Math.min(maxDate.getTime(), adapter!.addDays(valueToDate(value), 5).getTime()))) + ); + } else { + setValue(dateToValue(minDate)); + } + + return; + } + case 'ArrowDown': + case '-': { + if (filled) { + setValue( + dateToValue(new Date(Math.max(minDate.getTime(), adapter!.addDays(valueToDate(value), -1).getTime()))) + ); + } else { + setValue(dateToValue(maxDate)); + } + + return; + } + case 'PageDown': { + if (filled) { + setValue( + dateToValue(new Date(Math.max(minDate.getTime(), adapter!.addDays(valueToDate(value), -5).getTime()))) + ); + } else { + setValue(dateToValue(maxDate)); + } + + return; + } + case 'Home': { + setValue(dateToValue(minDate)); + return; + } + case 'End': { + setValue(dateToValue(maxDate)); + return; + } + case 'Delete': + case 'Backspace': { + setValue(EMPTY); + return; + } + } + + return; + } + + const element = getAnchorElement(); + + if (!element) { + return; + } + + const token = element.dataset.token; + + if (!token) { + return; + } + + const field = DateInputRegistry.get(token); + + if (!field) { + return; + } + + const formatTokens = Array.from(ref.current.querySelectorAll('span[data-token]')) as HTMLElement[]; + + const selectPrev = () => { + if (selection) { + const index = formatTokens.indexOf(element); + + if (index !== -1) { + const prevElement = formatTokens[index - 1] || formatTokens[formatTokens.length - 1]; + select(prevElement); + } + } + }; + + const selectNext = () => { + if (selection) { + const index = formatTokens.indexOf(element); + + if (index !== -1) { + const nextElement = formatTokens[index + 1] || formatTokens[0]; + select(nextElement); + } + } + }; + + if (e.key === 'Delete') { + setValue((prev) => ({ ...prev, [field.field]: '' })); + select(element); + return; + } + + if (e.key === 'Backspace') { + if (value[field.field]) { + setValue((prev) => ({ ...prev, [field.field]: prev[field.field].substring(0, prev[field.field].length - 1) })); + select(element); + } else { + selectPrev(); + } + + return; + } + + if (e.key === 'ArrowLeft') { + selectPrev(); + return; + } + + if (e.key === 'ArrowRight') { + selectNext(); + return; + } + + if (e.key === 'ArrowUp' || e.key === '+') { + setValue((prev) => ({ ...prev, [field.field]: field.getNext(value[field.field], 1, value) })); + select(element); + return; + } + + if (e.key === 'ArrowDown' || e.key === '-') { + setValue((prev) => ({ ...prev, [field.field]: field.getPrev(value[field.field], 1, value) })); + select(element); + return; + } + + if (e.key === 'PageUp') { + setValue((prev) => ({ ...prev, [field.field]: field.getNext(value[field.field], 5, value) })); + select(element); + return; + } + + if (e.key === 'PageDown') { + setValue((prev) => ({ ...prev, [field.field]: field.getPrev(value[field.field], 5, value) })); + select(element); + return; + } + + const variants = field.getValues(value[field.field], e.key, value); + + if (variants.length) { + setValue((prev) => ({ ...prev, [field.field]: variants[0] })); + + if (variants.length === 1) { + if (selection) { + const index = formatTokens.indexOf(element); + + if (index !== -1) { + const nextElement = formatTokens[index + 1]; + + if (nextElement) { + select(nextElement); + } + } + } + } else { + select(element); + } + } else { + // TODO: Blinking? + } + }; + + const onBlur = () => { + setCurrentToken(null); + + requestAnimationFrame(() => { + const selection = window.getSelection(); + + if (ref.current && selection && ref.current.contains(selection.anchorNode)) { + selection.removeAllRanges(); + } + }); + }; + + const onFocus = () => { + if (ref.current) { + select(ref.current); + } + }; + + const onMouseUp = (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + const element = getAnchorElement(target === ref.current ? (target.lastChild as HTMLElement) : target); + + if (element) { + select(element); + } + }; + + const onDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'none'; + }; + + return ( +
+ {tokens.map((token, index) => { + const field = DateInputRegistry.get(token); + + return ( + + {field ? field.getPlaceholder(value[field.field], currentToken === token, value) : token} + + ); + })} +
+ ); +}; diff --git a/packages/react/src/components/DateInput/DateInput.types.ts b/packages/react/src/components/DateInput/DateInput.types.ts new file mode 100644 index 000000000..baeec3779 --- /dev/null +++ b/packages/react/src/components/DateInput/DateInput.types.ts @@ -0,0 +1,45 @@ +import { ReactNode } from 'react'; + +export interface DateInputProps { + /** */ + name?: string; + /** */ + value?: Date | null; + /** + * Supported tokens are 'YYYY', 'MM', 'dd', 'HH', 'mm' + */ + format?: string; + + /** */ + minDate?: Date; + /** */ + maxDate?: Date; + + /** + * ... + * @default 1 + */ + increment?: number; + + /** + * ... + * @default 5 + */ + incrementLeap?: number; + + /** Callback fired when valid date is entered. */ + onChange?: (value: Date | null) => void; + /** */ + onKeyDown?: React.KeyboardEventHandler; +} + +export type DateInputField = 'year' | 'month' | 'date' | 'hours' | 'minutes' | 'seconds' | 'milliseconds'; + +export interface DateInputFormatter { + field: DateInputField; + getDefaultValue: () => string; + getPlaceholder: (value: string, selected: boolean, context: Record) => ReactNode; + getValues: (value: string, key: string, context: Record) => string[]; + getPrev: (value: string, step: number, context: Record) => string; + getNext: (value: string, step: number, context: Record) => string; +} diff --git a/packages/react/src/components/DateInput/DateInput.utils.tsx b/packages/react/src/components/DateInput/DateInput.utils.tsx new file mode 100644 index 000000000..0403a0fb6 --- /dev/null +++ b/packages/react/src/components/DateInput/DateInput.utils.tsx @@ -0,0 +1,74 @@ +import { ReactNode } from 'react'; + +import { DateInputField, DateInputFormatter } from './DateInput.types'; + +export const isNumber = (key: string | number) => { + return Number.isInteger(+key); +}; + +export const addWithLoop = (value: number, step: number, min: number, max: number) => { + let result = value + step; + + if (result > max) { + result = min; + } + + if (result < min) { + result = max; + } + + return result; +}; + +export const getGenericRangeValues = (min: number, max: number) => (value: string, key: string) => { + if (!isNumber(key)) { + return []; + } + + const variants: string[] = []; + + for (let i = min; i <= max; i++) { + variants.push(i.toString()); + } + + const requested = `${value}${key}`; + const possibleVariants = variants.filter((v) => v.startsWith(requested)); + + if (possibleVariants.length) { + return possibleVariants; + } + + const possibleBaseVariants = variants.filter((v) => v.startsWith(key.toString())); + + if (possibleBaseVariants.length) { + return possibleBaseVariants; + } + + return []; +}; + +export const getZeroPaddedPlaceholder = ( + that: DateInputFormatter, + value: string, + selected: boolean, + context: Record, + maxLength: number, + placeholder: string +): ReactNode => { + if (value) { + if (selected && that.getValues(value, '', context).length > 1) { + return ( + <> + {value} + {value.length < maxLength && ( + {new Array(maxLength + 1 - value.length).join(placeholder)} + )} + + ); + } + + return value.padStart(maxLength, '0'); + } + + return {new Array(maxLength + 1).join(placeholder)}; +}; diff --git a/packages/react/src/components/DateInput/index.ts b/packages/react/src/components/DateInput/index.ts new file mode 100644 index 000000000..5a486cbbb --- /dev/null +++ b/packages/react/src/components/DateInput/index.ts @@ -0,0 +1 @@ +export { DateInput } from './DateInput'; diff --git a/packages/theme/lib/components/_date-input.scss b/packages/theme/lib/components/_date-input.scss new file mode 100644 index 000000000..4ca5b3a14 --- /dev/null +++ b/packages/theme/lib/components/_date-input.scss @@ -0,0 +1,20 @@ +@mixin include() { + .es-date-input { + caret-color: transparent; + + &, + & * { + &::selection { + background: var(--es-info-a500); + } + } + + & span[data-placeholder] { + color: var(--es-mono-a-a400); + } + + & span[data-node]:has(span[data-placeholder]) + span:not([data-token]) { + color: var(--es-mono-a-a400); + } + } +} diff --git a/packages/theme/lib/components/_index.scss b/packages/theme/lib/components/_index.scss index c7d329c39..f88738f43 100644 --- a/packages/theme/lib/components/_index.scss +++ b/packages/theme/lib/components/_index.scss @@ -16,6 +16,7 @@ @use './checkbox'; @use './chip'; @use './chips'; +@use './date-input'; @use './dialog'; @use './divider'; @use './dropzone'; @@ -84,6 +85,7 @@ @include checkbox.include; @include chip.include; @include chips.include; + @include date-input.include; @include dialog.include; @include divider.include; @include dropzone.include;