diff --git a/.eslintrc.js b/.eslintrc.js index 195537f..8112c87 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,6 +18,7 @@ module.exports = { 'import/no-unresolved': 0, 'import/no-extraneous-dependencies': 0, 'no-shadow': 0, + 'no-nested-ternary': 0, 'react/prop-types': 0, 'react/require-default-props': 0, 'react/jsx-fragments': 0, @@ -25,6 +26,7 @@ module.exports = { 2, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }, ], + 'react/jsx-curly-newline': 0, 'jsx-a11y/no-noninteractive-element-interactions': 0, 'react/jsx-props-no-spreading': 0, '@typescript-eslint/explicit-function-return-type': [ diff --git a/example/src/stories/DateSelect.stories.tsx b/example/src/stories/DateSelect.stories.tsx new file mode 100644 index 0000000..856a203 --- /dev/null +++ b/example/src/stories/DateSelect.stories.tsx @@ -0,0 +1,107 @@ +import { Centering, DateSelect } from '@solved-ac/ui-react' +import { Meta, StoryObj } from '@storybook/react' +import React from 'react' + +export default { + title: 'Components/DateSelect', + component: DateSelect, + argTypes: { + locale: { + control: 'text', + description: 'The locale to use', + }, + weekStartsOn: { + defaultValue: 0, + options: [0, 1, 2, 3, 4, 5, 6], + control: { + type: 'select', + }, + description: 'The day of the week to start on', + }, + chunks: { + defaultValue: 1, + options: [1, 2, 3, 4, 5, 6], + control: { + type: 'select', + }, + description: 'The number of months to display at once', + }, + annotations: { + control: 'array', + description: 'The annotations to display', + defaultValue: [], + }, + }, +} as Meta + +type Story = StoryObj + +export const Default: Story = { + render: (args) => ( + + + + ), +} + +const DAY = 1000 * 60 * 60 * 24 + +export const Annotations: Story = { + render: (args) => ( + + + + ), + args: { + annotations: [ + { + title: "I'm a title", + color: '#e3f44f', + start: new Date().toISOString().split('T')[0], + end: new Date(Date.now() + DAY * 7).toISOString().split('T')[0], + }, + { + title: "I'm a title", + color: '#c1ecff', + start: new Date(Date.now() - DAY * 17).toISOString().split('T')[0], + end: new Date(Date.now() - DAY * 5).toISOString().split('T')[0], + }, + { + title: "I'm a title", + color: '#ffd2bd', + start: new Date(Date.now() + DAY * 4).toISOString().split('T')[0], + end: new Date(Date.now() + DAY * 16).toISOString().split('T')[0], + }, + { + title: "I'm a title", + color: '#ffc8fb', + start: new Date(Date.now() + DAY * 9).toISOString().split('T')[0], + end: new Date(Date.now() + DAY * 9).toISOString().split('T')[0], + }, + { + title: "I'm a title", + color: '#f9ffc1', + start: new Date(Date.now() + DAY * 16).toISOString().split('T')[0], + end: new Date(Date.now() + DAY * 17).toISOString().split('T')[0], + }, + { + title: "I'm a title", + color: '#d7ffc0', + start: new Date(Date.now() + DAY * 24).toISOString().split('T')[0], + end: new Date(Date.now() + DAY * 28).toISOString().split('T')[0], + }, + { + title: "I'm a title", + color: '#b9b7b4', + start: new Date(Date.now() + DAY * 21).toISOString().split('T')[0], + end: new Date(Date.now() + DAY * 25).toISOString().split('T')[0], + }, + { + title: "I'm a title", + color: '#ffcbcb', + start: new Date(Date.now() + DAY * 34).toISOString().split('T')[0], + end: new Date(Date.now() + DAY * 36).toISOString().split('T')[0], + }, + ], + }, +} diff --git a/src/components/$DateSelect/DateSelect.tsx b/src/components/$DateSelect/DateSelect.tsx new file mode 100644 index 0000000..23e9899 --- /dev/null +++ b/src/components/$DateSelect/DateSelect.tsx @@ -0,0 +1,124 @@ +import styled from '@emotion/styled' +import React, { ElementType, useEffect, useState } from 'react' +import { PP, PR } from '../../types/PolymorphicElementProps' +import { forwardRefWithGenerics } from '../../utils/ref' +import { DateSelectContext } from './DateSelectContext' +import { DateSelectMonthView } from './DateSelectMonthView' + +export interface DateRange { + start: string + end: string +} + +export type DateSelectValues = + | { + type: 'date' + value: string + onChange?: (value: string) => void + } + | { + type: 'date-range' + value: DateRange + onChange?: (value: DateRange) => void + } + +export interface DateSelectAnnotation extends DateRange { + title?: string + color?: string +} + +export type DateSelectProps = DateSelectValues & { + annotations?: DateSelectAnnotation[] + maxAnnotationsPerDay?: number + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 + locale?: string + chunks?: number +} + +export type DateSelectMode = 'year' | 'month' | 'date' +export type CursorMode = 'select' | 'selectStart' | 'selectEnd' + +export type DateSelectCursor = + | { + mode: 'select' + hover: Date | null + } + | { + mode: 'selectStart' + hover: Date | null + } + | { + mode: 'selectEnd' + valueStart: Date + hover: Date | null + } + +// const toDateString = (date: Date): string => { +// return date.toISOString().split('T')[0] +// } + +// const fromDateString = (date: string): Date => { +// return new Date(date) +// } + +const DateSelectContainer = styled.div` + user-select: none; + display: flex; + gap: 1em; +` + +export const DateSelect = forwardRefWithGenerics( + (props: PP, ref?: PR) => { + const { + type, + value, + onChange, + annotations = [], + maxAnnotationsPerDay = annotations.length ? 3 : 0, + weekStartsOn = 0, + locale, + chunks = 1, + ...rest + } = props + // const theme = useTheme() + + const [currentMode, setCurrentMode] = useState('date') + const [selectState, setSelectState] = useState({ + mode: type === 'date' ? ('select' as const) : ('selectStart' as const), + hover: null, + }) + const [cursorDate, setCursorDate] = useState(new Date()) + + useEffect(() => { + setSelectState({ + mode: type === 'date' ? ('select' as const) : ('selectStart' as const), + hover: null, + }) + }, [type]) + + // const selectedYear = selectedDate.getFullYear() + // const selectedMonth = selectedDate.getMonth() + + return ( + + + {currentMode === 'date' && + new Array(chunks).fill(0).map((_, index) => ( + setCurrentMode('month')} + firstMonth={index === 0} + lastMonth={index === chunks - 1} + /> + ))} + + + ) + } +) diff --git a/src/components/$DateSelect/DateSelectContext.tsx b/src/components/$DateSelect/DateSelectContext.tsx new file mode 100644 index 0000000..fc1f9ca --- /dev/null +++ b/src/components/$DateSelect/DateSelectContext.tsx @@ -0,0 +1,13 @@ +import React, { useContext } from 'react' +import { DateSelectProps } from './DateSelect' + +export const DateSelectContext = React.createContext({ + type: 'date', + value: '2020-06-05', + onChange: () => { + /* no-op */ + }, +}) + +export const useDateSelectContext = (): DateSelectProps => + useContext(DateSelectContext) diff --git a/src/components/$DateSelect/DateSelectMonthView.tsx b/src/components/$DateSelect/DateSelectMonthView.tsx new file mode 100644 index 0000000..bcde585 --- /dev/null +++ b/src/components/$DateSelect/DateSelectMonthView.tsx @@ -0,0 +1,408 @@ +import { useTheme } from '@emotion/react' +import styled from '@emotion/styled' +import { IconArrowLeft, IconArrowRight } from '@tabler/icons-react' +import { ellipsis } from 'polished' +import React from 'react' +import { resolveLocale } from '../../utils/locale' +import { Button } from '../Button' +import { Typo } from '../Typo' +import { DateSelectAnnotation, DateSelectCursor } from './DateSelect' +import { useDateSelectContext } from './DateSelectContext' + +const DateSelectContainer = styled.div` + display: flex; + flex-direction: column; +` + +const DateSelectGrid = styled.div` + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-template-rows: 1.5em repeat(6, 1fr); + justify-items: center; +` + +const DateContainer = styled.div<{ maxAnnotationsPerDay?: number }>` + ${ellipsis()} + width:${({ maxAnnotationsPerDay: n }) => (n ? 3.5 : 2.5)}em; + height: ${({ maxAnnotationsPerDay: n }) => (n ? 1.6 + 0.6 * n : 2.5)}em; + text-align: center; + line-height: ${({ maxAnnotationsPerDay: n }) => (n ? 2 : 2.5)}em; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + position: relative; +` + +const DateIndicatorBackground = styled.div` + position: absolute; + left: 50%; + top: 50%; + width: 2em; + height: 2em; + border-radius: 9999px; + transform: translate(-50%, -50%); +` + +const DateHoverIndicator = styled(DateIndicatorBackground)` + background-color: ${({ theme }) => theme.color.background.card.dark}; + opacity: 0.5; +` + +const DateSelectedIndicator = styled(DateIndicatorBackground)` + background-color: ${({ theme }) => theme.color.solvedAc}; +` + +const DateHoverRangeIndicator = styled(DateIndicatorBackground)<{ + side?: 'left' | 'right' +}>` + width: ${({ side }) => (side ? '50%' : '100%')}; + left: ${({ side }) => (side === 'right' ? '0' : 'unset')}; + right: ${({ side }) => (side === 'right' ? 'unset' : '0')}; + transform: translate(0, -50%); + border-radius: 0; + background-color: ${({ theme }) => theme.color.background.card.main}; +` + +const DateIndicator = styled(Typo)` + display: block; + position: relative; + width: 100%; +` + +const AnnotationContainer = styled.div` + width: 100%; + height: 0.6em; + display: flex; + align-items: center; + padding-bottom: 0.1em; +` + +const AnnotationElement = styled.div` + height: 100%; + width: 100%; +` + +const DateHeader = styled(DateContainer)` + width: ${2.5 / 0.75}em; + height: ${1.5 / 0.75}em; + line-height: ${1.5 / 0.75}em; + font-size: 75%; +` + +const MonthCaptionContainer = styled.div` + display: flex; + align-items: center; +` + +const MonthCaption = styled.div` + ${ellipsis()} + height: 3em; + flex: 1; + min-width: 0; + display: flex; + justify-content: center; + align-items: center; +` + +const MonthNavigationButton = styled(Button)` + width: 3em; + height: 3em; + font-size: 1em; + padding: 0; + display: flex; + justify-content: center; + align-items: center; +` + +const DAY = 24 * 60 * 60 * 1000 + +export interface DateSelectMonthView { + cursorDate: Date + setCursorDate: (date: Date) => void + setModeToMonth: () => void + firstMonth: boolean + lastMonth: boolean + offset: number + selectState: DateSelectCursor + setSelectState: React.Dispatch> +} + +export const DateSelectMonthView = ( + props: DateSelectMonthView +): JSX.Element => { + const context = useDateSelectContext() + + const { + value, + onChange, + annotations = [], + maxAnnotationsPerDay = annotations.length ? 3 : 0, + weekStartsOn = 0, + locale, + } = context + + const { + cursorDate, + setCursorDate, + selectState, + setSelectState, + setModeToMonth, + firstMonth, + lastMonth, + offset, + } = props + + const theme = useTheme() + + const resolvedLocale = resolveLocale(locale) + + const renderDateObject = new Date( + cursorDate.getFullYear(), + cursorDate.getMonth() + offset, + 1 + ) + const renderYear = renderDateObject.getFullYear() + const renderMonth = renderDateObject.getMonth() + const currentMonthLastDate = new Date(renderYear, renderMonth + 1, 0) + // const currentMonthDates = currentMonthLastDate.getDate() + + // 0 = sunday + const currentMonth1stDate = new Date(renderYear, renderMonth, 1) + const currentMonth1stDay = currentMonth1stDate.getDay() + const firstWeekFirstDateDelta = (7 + currentMonth1stDay - weekStartsOn) % 7 + const firstWeekFirstDateCandidate = new Date( + renderYear, + renderMonth, + 1 - firstWeekFirstDateDelta + ) + + const minimumRenderCalendarDateCount = + currentMonthLastDate.getTime() / DAY - + firstWeekFirstDateCandidate.getTime() / DAY + + 1 + + const currentMonthRenderWeeks = Math.ceil(minimumRenderCalendarDateCount / 7) + const firstWeekFirstDate = new Date( + firstWeekFirstDateCandidate.getTime() + + (currentMonthRenderWeeks === 4 ? -7 : 0) * DAY + ) + const lastDate = new Date(firstWeekFirstDate.getTime() + 6 * 7 * DAY) + const firstWeekFirstDateString = firstWeekFirstDate + .toISOString() + .split('T')[0] + const lastDateString = lastDate.toISOString().split('T')[0] + + const hoveredDate = selectState.hover?.toISOString().split('T')[0] + const inputSelectedRangeA = + (selectState.mode === 'selectEnd' ? selectState.valueStart : null) + ?.toISOString() + .split('T')[0] || null + const inputSelectedRangeB = + selectState.mode === 'selectEnd' ? hoveredDate || null : null + const inputSelectedRangeStart = + (inputSelectedRangeA || '0') < (inputSelectedRangeB || '0') + ? inputSelectedRangeA + : inputSelectedRangeB + const inputSelectedRangeEnd = + (inputSelectedRangeA || '0') > (inputSelectedRangeB || '0') + ? inputSelectedRangeA + : inputSelectedRangeB + + const handleNavigateMonth = (delta: number): void => { + const destinationMonth1stDate = new Date( + cursorDate.getFullYear(), + cursorDate.getMonth() + delta, + 1 + ) + const destinationMonthLastDate = new Date( + destinationMonth1stDate.getFullYear(), + destinationMonth1stDate.getMonth() + 1, + 0 + ) + const destinationDate = Math.min( + cursorDate.getDate(), + destinationMonthLastDate.getDate() + ) + const destination = new Date( + destinationMonth1stDate.getFullYear(), + destinationMonth1stDate.getMonth(), + destinationDate + ) + setCursorDate(destination) + } + + const handleSelectDate = (date: string): void => { + if (context.type === 'date') { + if (context.onChange) { + context.onChange(date) + } + return + } + + const { onChange: onChangeRange } = context + if (selectState.mode === 'selectStart') { + setSelectState((prev) => ({ + ...prev, + mode: 'selectEnd', + valueStart: new Date(date), + })) + } else if (selectState.mode === 'selectEnd' && inputSelectedRangeStart) { + if (onChangeRange) { + onChangeRange({ + start: inputSelectedRangeStart, + end: date, + }) + } + setSelectState((prev) => ({ + mode: 'selectStart', + hover: prev.hover, + })) + } + } + + const annotationsInRenderMonth = annotations + .filter( + (annotation) => + annotation.start <= lastDateString || + annotation.end >= firstWeekFirstDateString, + [firstWeekFirstDate, lastDate] + ) + .sort((a, b) => a.start.localeCompare(b.start)) + + const annotationsGreedilyBucketed = + maxAnnotationsPerDay > 0 + ? annotationsInRenderMonth.reduce((acc, cur) => { + const applicable = acc.find( + (x) => + x.length === 0 || x[x.length - 1].end.localeCompare(cur.start) < 0 + ) + if (!applicable) return acc + applicable.push(cur) + return acc + }, new Array(maxAnnotationsPerDay).fill(undefined).map(() => []) as DateSelectAnnotation[][]) + : [] + + return ( + + + {firstMonth && ( + handleNavigateMonth(-1)} + > + + + )} + setModeToMonth()}> + {currentMonth1stDate.toLocaleDateString(resolvedLocale, { + month: 'long', + year: 'numeric', + })} + + {lastMonth && ( + handleNavigateMonth(1)} + > + + + )} + + + {new Array(7).fill(undefined).map((_, dayOffset) => { + const date = new Date(firstWeekFirstDate.getTime() + dayOffset * DAY) + return ( + + {date.toLocaleDateString(resolvedLocale, { weekday: 'short' })} + + ) + })} + {new Array(42).fill(undefined).map((_, dateOffset) => { + const date = new Date(firstWeekFirstDate.getTime() + dateOffset * DAY) + const dateString = date.toISOString().split('T')[0] + + const currentDateInHoverRange = + inputSelectedRangeStart && inputSelectedRangeEnd + ? dateString >= inputSelectedRangeStart && + dateString <= inputSelectedRangeEnd + : false + + return ( + + setSelectState((prev) => ({ + ...prev, + hover: date, + })) + } + onClick={() => handleSelectDate(dateString)} + > + + {currentDateInHoverRange && ( + + )} + {inputSelectedRangeA === dateString && ( + + )} + {hoveredDate === dateString && } + + {date.getDate()} + + + {annotationsGreedilyBucketed.map((annotationsInDay, index) => { + const annotation = annotationsInDay.find( + ({ start, end }) => start <= dateString && end >= dateString, + [date] + ) + if (!annotation) + return ( + + ) + const isAnnotationFirst = annotation.start === dateString + const isAnnotationLast = annotation.end === dateString + return ( + + + + ) + })} + + ) + })} + + + ) +} diff --git a/src/components/$DateSelect/index.ts b/src/components/$DateSelect/index.ts new file mode 100644 index 0000000..e18fdec --- /dev/null +++ b/src/components/$DateSelect/index.ts @@ -0,0 +1,2 @@ +export * from './DateSelect'; + diff --git a/src/components/index.ts b/src/components/index.ts index 0827737..43d7c97 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,3 +1,4 @@ +export * from './$DateSelect' export * from './$Item' export * from './$List' export * from './$Tab' diff --git a/src/utils/locale.ts b/src/utils/locale.ts new file mode 100644 index 0000000..8e9aebe --- /dev/null +++ b/src/utils/locale.ts @@ -0,0 +1,11 @@ +export const resolveLocale = ( + localeString: string | undefined +): string | undefined => { + if (!localeString) return undefined + try { + new Date().toLocaleString(localeString) + return localeString + } catch (error) { + return undefined + } +} diff --git a/tsconfig.json b/tsconfig.json index 63f53ac..35b5ee6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,8 +15,6 @@ "noImplicitAny": true, "strictNullChecks": true, "suppressImplicitAnyIndexErrors": true, - "noUnusedLocals": true, - "noUnusedParameters": true, "allowSyntheticDefaultImports": true }, "include": ["src"],