Skip to content

Commit 3337d09

Browse files
committed
feat: basic interactions to range select
1 parent 415f97a commit 3337d09

File tree

5 files changed

+212
-45
lines changed

5 files changed

+212
-45
lines changed

.eslintrc.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ module.exports = {
1818
'import/no-unresolved': 0,
1919
'import/no-extraneous-dependencies': 0,
2020
'no-shadow': 0,
21+
'no-nested-ternary': 0,
2122
'react/prop-types': 0,
2223
'react/require-default-props': 0,
2324
'react/jsx-fragments': 0,
2425
'react/jsx-filename-extension': [
2526
2,
2627
{ extensions: ['.js', '.jsx', '.ts', '.tsx'] },
2728
],
29+
'react/jsx-curly-newline': 0,
2830
'jsx-a11y/no-noninteractive-element-interactions': 0,
2931
'react/jsx-props-no-spreading': 0,
3032
'@typescript-eslint/explicit-function-return-type': [

src/components/$DateSelect/DateSelect.tsx

Lines changed: 51 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import styled from '@emotion/styled'
2-
import React, { ElementType, useState } from 'react'
2+
import React, { ElementType, useEffect, useState } from 'react'
33
import { PP, PR } from '../../types/PolymorphicElementProps'
44
import { forwardRefWithGenerics } from '../../utils/ref'
5+
import { DateSelectContext } from './DateSelectContext'
56
import { DateSelectMonthView } from './DateSelectMonthView'
67

78
export interface DateRange {
@@ -13,12 +14,12 @@ export type DateSelectValues =
1314
| {
1415
type: 'date'
1516
value: string
16-
onChange: (value: string) => void
17+
onChange?: (value: string) => void
1718
}
1819
| {
1920
type: 'date-range'
2021
value: DateRange
21-
onChange: (value: DateRange) => void
22+
onChange?: (value: DateRange) => void
2223
}
2324

2425
export interface DateSelectAnnotation extends DateRange {
@@ -35,6 +36,22 @@ export type DateSelectProps = DateSelectValues & {
3536
}
3637

3738
export type DateSelectMode = 'year' | 'month' | 'date'
39+
export type CursorMode = 'select' | 'selectStart' | 'selectEnd'
40+
41+
export type DateSelectCursor =
42+
| {
43+
mode: 'select'
44+
hover: Date | null
45+
}
46+
| {
47+
mode: 'selectStart'
48+
hover: Date | null
49+
}
50+
| {
51+
mode: 'selectEnd'
52+
valueStart: Date
53+
hover: Date | null
54+
}
3855

3956
// const toDateString = (date: Date): string => {
4057
// return date.toISOString().split('T')[0]
@@ -66,28 +83,42 @@ export const DateSelect = forwardRefWithGenerics(
6683
// const theme = useTheme()
6784

6885
const [currentMode, setCurrentMode] = useState<DateSelectMode>('date')
69-
const [selectedDate, setSelectedDate] = useState<Date>(new Date())
86+
const [selectState, setSelectState] = useState<DateSelectCursor>({
87+
mode: type === 'date' ? ('select' as const) : ('selectStart' as const),
88+
hover: null,
89+
})
90+
const [cursorDate, setCursorDate] = useState<Date>(new Date())
91+
92+
useEffect(() => {
93+
setSelectState({
94+
mode: type === 'date' ? ('select' as const) : ('selectStart' as const),
95+
hover: null,
96+
})
97+
}, [type])
7098

7199
// const selectedYear = selectedDate.getFullYear()
72100
// const selectedMonth = selectedDate.getMonth()
73101

74102
return (
75-
<DateSelectContainer {...rest} ref={ref}>
76-
{currentMode === 'date' &&
77-
new Array(chunks).fill(0).map((_, index) => (
78-
<DateSelectMonthView
79-
{...props}
80-
// eslint-disable-next-line react/no-array-index-key
81-
key={index}
82-
offset={index - Math.floor(chunks / 2)}
83-
selectedDate={selectedDate}
84-
setSelectedDate={setSelectedDate}
85-
setModeToMonth={() => setCurrentMode('month')}
86-
firstMonth={index === 0}
87-
lastMonth={index === chunks - 1}
88-
/>
89-
))}
90-
</DateSelectContainer>
103+
<DateSelectContext.Provider value={props}>
104+
<DateSelectContainer {...rest} ref={ref}>
105+
{currentMode === 'date' &&
106+
new Array(chunks).fill(0).map((_, index) => (
107+
<DateSelectMonthView
108+
// eslint-disable-next-line react/no-array-index-key
109+
key={index}
110+
offset={index - Math.floor(chunks / 2)}
111+
cursorDate={cursorDate}
112+
setCursorDate={setCursorDate}
113+
selectState={selectState}
114+
setSelectState={setSelectState}
115+
setModeToMonth={() => setCurrentMode('month')}
116+
firstMonth={index === 0}
117+
lastMonth={index === chunks - 1}
118+
/>
119+
))}
120+
</DateSelectContainer>
121+
</DateSelectContext.Provider>
91122
)
92123
}
93124
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import React, { useContext } from 'react'
2+
import { DateSelectProps } from './DateSelect'
3+
4+
export const DateSelectContext = React.createContext<DateSelectProps>({
5+
type: 'date',
6+
value: '2020-06-05',
7+
onChange: () => {
8+
/* no-op */
9+
},
10+
})
11+
12+
export const useDateSelectContext = (): DateSelectProps =>
13+
useContext(DateSelectContext)

src/components/$DateSelect/DateSelectMonthView.tsx

Lines changed: 146 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import React from 'react'
66
import { resolveLocale } from '../../utils/locale'
77
import { Button } from '../Button'
88
import { Typo } from '../Typo'
9-
import { DateSelectAnnotation, DateSelectProps } from './DateSelect'
9+
import { DateSelectAnnotation, DateSelectCursor } from './DateSelect'
10+
import { useDateSelectContext } from './DateSelectContext'
1011

1112
const DateSelectContainer = styled.div`
1213
display: flex;
@@ -30,6 +31,43 @@ const DateContainer = styled.div<{ maxAnnotationsPerDay?: number }>`
3031
flex-direction: column;
3132
justify-content: flex-start;
3233
align-items: center;
34+
position: relative;
35+
`
36+
37+
const DateIndicatorBackground = styled.div`
38+
position: absolute;
39+
left: 50%;
40+
top: 50%;
41+
width: 2em;
42+
height: 2em;
43+
border-radius: 9999px;
44+
transform: translate(-50%, -50%);
45+
`
46+
47+
const DateHoverIndicator = styled(DateIndicatorBackground)`
48+
background-color: ${({ theme }) => theme.color.background.card.dark};
49+
opacity: 0.5;
50+
`
51+
52+
const DateSelectedIndicator = styled(DateIndicatorBackground)`
53+
background-color: ${({ theme }) => theme.color.solvedAc};
54+
`
55+
56+
const DateHoverRangeIndicator = styled(DateIndicatorBackground)<{
57+
side?: 'left' | 'right'
58+
}>`
59+
width: ${({ side }) => (side ? '50%' : '100%')};
60+
left: ${({ side }) => (side === 'right' ? '0' : 'unset')};
61+
right: ${({ side }) => (side === 'right' ? 'unset' : '0')};
62+
transform: translate(0, -50%);
63+
border-radius: 0;
64+
background-color: ${({ theme }) => theme.color.background.card.main};
65+
`
66+
67+
const DateIndicator = styled(Typo)`
68+
display: block;
69+
position: relative;
70+
width: 100%;
3371
`
3472

3573
const AnnotationContainer = styled.div`
@@ -79,28 +117,37 @@ const MonthNavigationButton = styled(Button)`
79117

80118
const DAY = 24 * 60 * 60 * 1000
81119

82-
type DateSelectMonthView = DateSelectProps & {
83-
selectedDate: Date
84-
setSelectedDate: (date: Date) => void
120+
export interface DateSelectMonthView {
121+
cursorDate: Date
122+
setCursorDate: (date: Date) => void
85123
setModeToMonth: () => void
86124
firstMonth: boolean
87125
lastMonth: boolean
88126
offset: number
127+
selectState: DateSelectCursor
128+
setSelectState: React.Dispatch<React.SetStateAction<DateSelectCursor>>
89129
}
90130

91131
export const DateSelectMonthView = (
92132
props: DateSelectMonthView
93133
): JSX.Element => {
134+
const context = useDateSelectContext()
135+
94136
const {
95-
// value,
96-
// onChange,
137+
value,
138+
onChange,
97139
annotations = [],
98140
maxAnnotationsPerDay = annotations.length ? 3 : 0,
99141
weekStartsOn = 0,
100-
selectedDate,
101-
setSelectedDate,
102-
setModeToMonth,
103142
locale,
143+
} = context
144+
145+
const {
146+
cursorDate,
147+
setCursorDate,
148+
selectState,
149+
setSelectState,
150+
setModeToMonth,
104151
firstMonth,
105152
lastMonth,
106153
offset,
@@ -111,8 +158,8 @@ export const DateSelectMonthView = (
111158
const resolvedLocale = resolveLocale(locale)
112159

113160
const renderDateObject = new Date(
114-
selectedDate.getFullYear(),
115-
selectedDate.getMonth() + offset,
161+
cursorDate.getFullYear(),
162+
cursorDate.getMonth() + offset,
116163
1
117164
)
118165
const renderYear = renderDateObject.getFullYear()
@@ -146,10 +193,26 @@ export const DateSelectMonthView = (
146193
.split('T')[0]
147194
const lastDateString = lastDate.toISOString().split('T')[0]
148195

196+
const hoveredDate = selectState.hover?.toISOString().split('T')[0]
197+
const inputSelectedRangeA =
198+
(selectState.mode === 'selectEnd' ? selectState.valueStart : null)
199+
?.toISOString()
200+
.split('T')[0] || null
201+
const inputSelectedRangeB =
202+
selectState.mode === 'selectEnd' ? hoveredDate || null : null
203+
const inputSelectedRangeStart =
204+
(inputSelectedRangeA || '0') < (inputSelectedRangeB || '0')
205+
? inputSelectedRangeA
206+
: inputSelectedRangeB
207+
const inputSelectedRangeEnd =
208+
(inputSelectedRangeA || '0') > (inputSelectedRangeB || '0')
209+
? inputSelectedRangeA
210+
: inputSelectedRangeB
211+
149212
const handleNavigateMonth = (delta: number): void => {
150213
const destinationMonth1stDate = new Date(
151-
selectedDate.getFullYear(),
152-
selectedDate.getMonth() + delta,
214+
cursorDate.getFullYear(),
215+
cursorDate.getMonth() + delta,
153216
1
154217
)
155218
const destinationMonthLastDate = new Date(
@@ -158,15 +221,44 @@ export const DateSelectMonthView = (
158221
0
159222
)
160223
const destinationDate = Math.min(
161-
selectedDate.getDate(),
224+
cursorDate.getDate(),
162225
destinationMonthLastDate.getDate()
163226
)
164227
const destination = new Date(
165228
destinationMonth1stDate.getFullYear(),
166229
destinationMonth1stDate.getMonth(),
167230
destinationDate
168231
)
169-
setSelectedDate(destination)
232+
setCursorDate(destination)
233+
}
234+
235+
const handleSelectDate = (date: string): void => {
236+
if (context.type === 'date') {
237+
if (context.onChange) {
238+
context.onChange(date)
239+
}
240+
return
241+
}
242+
243+
const { onChange: onChangeRange } = context
244+
if (selectState.mode === 'selectStart') {
245+
setSelectState((prev) => ({
246+
...prev,
247+
mode: 'selectEnd',
248+
valueStart: new Date(date),
249+
}))
250+
} else if (selectState.mode === 'selectEnd' && inputSelectedRangeStart) {
251+
if (onChangeRange) {
252+
onChangeRange({
253+
start: inputSelectedRangeStart,
254+
end: date,
255+
})
256+
}
257+
setSelectState((prev) => ({
258+
mode: 'selectStart',
259+
hover: prev.hover,
260+
}))
261+
}
170262
}
171263

172264
const annotationsInRenderMonth = annotations
@@ -231,19 +323,50 @@ export const DateSelectMonthView = (
231323
{new Array(42).fill(undefined).map((_, dateOffset) => {
232324
const date = new Date(firstWeekFirstDate.getTime() + dateOffset * DAY)
233325
const dateString = date.toISOString().split('T')[0]
326+
327+
const currentDateInHoverRange =
328+
inputSelectedRangeStart && inputSelectedRangeEnd
329+
? dateString >= inputSelectedRangeStart &&
330+
dateString <= inputSelectedRangeEnd
331+
: false
332+
234333
return (
235334
<DateContainer
236335
key={date.toISOString()}
237336
maxAnnotationsPerDay={maxAnnotationsPerDay}
337+
onMouseEnter={() =>
338+
setSelectState((prev) => ({
339+
...prev,
340+
hover: date,
341+
}))
342+
}
343+
onClick={() => handleSelectDate(dateString)}
238344
>
239-
<Typo
240-
description={date.getMonth() !== renderMonth}
241-
style={{
242-
opacity: date.getMonth() !== renderMonth ? 0.5 : undefined,
243-
}}
244-
>
245-
{date.getDate()}
246-
</Typo>
345+
<DateIndicator>
346+
{currentDateInHoverRange && (
347+
<DateHoverRangeIndicator
348+
side={
349+
dateString === inputSelectedRangeStart
350+
? 'left'
351+
: dateString === inputSelectedRangeEnd
352+
? 'right'
353+
: undefined
354+
}
355+
/>
356+
)}
357+
{inputSelectedRangeA === dateString && (
358+
<DateSelectedIndicator />
359+
)}
360+
{hoveredDate === dateString && <DateHoverIndicator />}
361+
<DateIndicator
362+
description={date.getMonth() !== renderMonth}
363+
style={{
364+
opacity: date.getMonth() !== renderMonth ? 0.5 : undefined,
365+
}}
366+
>
367+
{date.getDate()}
368+
</DateIndicator>
369+
</DateIndicator>
247370
{annotationsGreedilyBucketed.map((annotationsInDay, index) => {
248371
const annotation = annotationsInDay.find(
249372
({ start, end }) => start <= dateString && end >= dateString,

tsconfig.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@
1515
"noImplicitAny": true,
1616
"strictNullChecks": true,
1717
"suppressImplicitAnyIndexErrors": true,
18-
"noUnusedLocals": true,
19-
"noUnusedParameters": true,
2018
"allowSyntheticDefaultImports": true
2119
},
2220
"include": ["src"],

0 commit comments

Comments
 (0)