Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactored Calendar component to remove dependency of primereact. (#2592 #2592

Merged
merged 14 commits into from
Mar 25, 2023
1 change: 0 additions & 1 deletion packages/neuron-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@
"immer": "9.0.16",
"jsqr": "1.4.0",
"office-ui-fabric-react": "7.199.6",
"primereact": "8.7.1",
"qr.js": "0.0.0",
"react": "17.0.2",
"react-dom": "17.0.2",
Expand Down
90 changes: 90 additions & 0 deletions packages/neuron-ui/src/tests/calendar/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
isDayInRange,
isMonthInRange,
isYearInRange,
isDateEqual,
getMonthCalendar,
} from '../../widgets/Calendar/utils'

describe('Check day in range', () => {
it('check no restrictions', () => {
expect(isDayInRange(new Date(2023, 1, 1), {})).toBe(true)
})
it('check minDate', () => {
expect(isDayInRange(new Date(2023, 1, 1), { minDate: new Date(2023, 1, 1) })).toBe(true)
expect(isDayInRange(new Date(2023, 1, 1), { minDate: new Date(2023, 1, 1, 12) })).toBe(true)
expect(isDayInRange(new Date(2023, 1, 1), { minDate: new Date(2023, 1, 2) })).toBe(false)
})
it('check maxDate', () => {
expect(isDayInRange(new Date(2023, 1, 2), { maxDate: new Date(2023, 1, 2) })).toBe(true)
expect(isDayInRange(new Date(2023, 1, 2), { maxDate: new Date(2023, 1, 1) })).toBe(false)
expect(isDayInRange(new Date(2023, 1, 2), { maxDate: new Date(2023, 1, 1, 12) })).toBe(false)
})
})

describe('Check month in range', () => {
it('check no restrictions', () => {
expect(isMonthInRange(2023, 1, {})).toBe(true)
})
it('check minDate', () => {
expect(isMonthInRange(2023, 1, { minDate: new Date(2023, 1, 1) })).toBe(false)
expect(isMonthInRange(2023, 1, { minDate: new Date(2023, 0, 31) })).toBe(true)
})
it('check maxDate', () => {
expect(isMonthInRange(2023, 2, { maxDate: new Date(2023, 1, 1) })).toBe(true)
expect(isMonthInRange(2023, 2, { maxDate: new Date(2023, 0, 31) })).toBe(false)
})
})

describe('Check year in range', () => {
it('check no restrictions', () => {
expect(isYearInRange(2023, {})).toBe(true)
})
it('check minDate', () => {
expect(isYearInRange(2023, { minDate: new Date(2023, 1, 1) })).toBe(true)
expect(isYearInRange(2023, { minDate: new Date(2024, 1, 1) })).toBe(false)
expect(isYearInRange(2023, { minDate: new Date(2022, 1, 1) })).toBe(true)
})
it('check maxDate', () => {
expect(isYearInRange(2023, { maxDate: new Date(2023, 1, 1) })).toBe(true)
expect(isYearInRange(2023, { maxDate: new Date(2024, 1, 1) })).toBe(true)
expect(isYearInRange(2023, { maxDate: new Date(2022, 1, 1) })).toBe(false)
})
})

describe('Check date equal', () => {
it('undefined in one side', () => {
expect(isDateEqual(new Date(2023, 2, 1), undefined)).toBe(false)
expect(isDateEqual(undefined, new Date(2023, 2, 1))).toBe(false)
})
it('check date equal', () => {
expect(isDateEqual(new Date(2023, 2, 1), new Date(2023, 2, 1))).toBe(true)
})
it('check date equal ignore time', () => {
expect(isDateEqual(new Date(2023, 2, 1, 12), new Date(2023, 2, 1, 18))).toBe(true)
})
})

describe('Generate monthly calendar data', () => {
it('Test month calendar output', () => {
expect(getMonthCalendar(2023, 1).map(week => week.map(date => date.label))).toEqual([
['2023/1/1', '2023/1/2', '2023/1/3', '2023/1/4', '2023/1/5', '2023/1/6', '2023/1/7'],
['2023/1/8', '2023/1/9', '2023/1/10', '2023/1/11', '2023/1/12', '2023/1/13', '2023/1/14'],
['2023/1/15', '2023/1/16', '2023/1/17', '2023/1/18', '2023/1/19', '2023/1/20', '2023/1/21'],
['2023/1/22', '2023/1/23', '2023/1/24', '2023/1/25', '2023/1/26', '2023/1/27', '2023/1/28'],
['2023/1/29', '2023/1/30', '2023/1/31', '2023/2/1', '2023/2/2', '2023/2/3', '2023/2/4'],
['2023/2/5', '2023/2/6', '2023/2/7', '2023/2/8', '2023/2/9', '2023/2/10', '2023/2/11'],
])
})

it('Test month canlendar with specified start weekday', () => {
expect(getMonthCalendar(2023, 1, 1).map(week => week.map(date => date.label))).toEqual([
['2022/12/26', '2022/12/27', '2022/12/28', '2022/12/29', '2022/12/30', '2022/12/31', '2023/1/1'],
['2023/1/2', '2023/1/3', '2023/1/4', '2023/1/5', '2023/1/6', '2023/1/7', '2023/1/8'],
['2023/1/9', '2023/1/10', '2023/1/11', '2023/1/12', '2023/1/13', '2023/1/14', '2023/1/15'],
['2023/1/16', '2023/1/17', '2023/1/18', '2023/1/19', '2023/1/20', '2023/1/21', '2023/1/22'],
['2023/1/23', '2023/1/24', '2023/1/25', '2023/1/26', '2023/1/27', '2023/1/28', '2023/1/29'],
['2023/1/30', '2023/1/31', '2023/2/1', '2023/2/2', '2023/2/3', '2023/2/4', '2023/2/5'],
])
})
})
90 changes: 90 additions & 0 deletions packages/neuron-ui/src/widgets/Calendar/calendar.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
@import '../../styles/mixin.scss';

@mixin button {
@include medium-text;
appearance: none;
cursor: pointer;
font-size: 0.75rem;
line-height: 1rem;
font-weight: 500;
padding: 7px 7px;
border: none;
border-radius: 2px;
margin: 0;
box-sizing: border-box;
border-radius: 2px;
background-color: transparent;
&:hover {
@include semi-bold-text;
background-color: #efefef;
}
&[disabled] {
cursor: not-allowed;
opacity: 0.5;
box-shadow: none !important;
pointer-events: none;
&:hover {
background-color: transparent;
}
}
}

.calendar {
width: 374px;
.calOptions {
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
list-style-type: none;
list-style: none;
padding: 0;
button {
@include button;
width: 100px;
}
}
}

.calendarHeader {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #eee;
margin-top: 10px;
margin-bottom: 10px;

.calPrev, .calNext {
@include button;
width: 30px;
}
.calTitle {
button {
@include button;
@include semi-bold-text;
height: 100%;
min-width: 100px;
margin-right: 10px;
}
}
}

.calendarTable {
width: 100%;

.calTableHeader {
@include semi-bold-text;
font-size: 13px;
}

.calDateItem {
@include button;
width: 100%;
&[aria-current="date"] {
border: 1px solid var(--nervos-green-light);
margin: -1px;
}
&[aria-pressed="true"] {
background-color: var(--nervos-green);
}
}
}
195 changes: 195 additions & 0 deletions packages/neuron-ui/src/widgets/Calendar/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import {
getMonthCalendar,
useLocalNames,
isMonthInRange,
isYearInRange,
isDateEqual,
isDayInRange,
WeekDayRange,
} from './utils'
import styles from './calendar.module.scss'

interface Option {
value: number
title: string
selectable: boolean
}
const Selector = ({ options, onChange }: { options: Option[]; onChange: (option: Option) => void }) => (
<ol className={styles.calOptions}>
{options.map(option => (
<li key={option.value} role="presentation">
<button
type="button"
aria-label={option.title}
title={option.title}
role="menuitem"
onClick={() => onChange(option)}
disabled={!option.selectable}
>
{option.title}
</button>
</li>
))}
</ol>
)

export interface CalendarProps {
value: Date | undefined
onChange: (value: Date) => void
firstDayOfWeek?: WeekDayRange
minDate?: Date
maxDate?: Date
}
const Calendar: React.FC<CalendarProps> = ({ value, onChange, minDate, maxDate, firstDayOfWeek = 0 }) => {
const [year, setYear] = useState(new Date().getFullYear())
const [month, setMonth] = useState(new Date().getMonth() + 1)
const [status, setStatus] = useState<'year' | 'month' | 'date'>('date')

useEffect(() => {
setYear(value?.getFullYear() ?? new Date().getFullYear())
setMonth((value?.getMonth() ?? new Date().getMonth()) + 1)
}, [value])

const locale = useLocalNames()
const weeknames = useMemo(() => Array.from({ length: 7 }, (_, i) => locale.dayNamesMin[(i + firstDayOfWeek) % 7]), [
locale,
])
const monthName = locale.monthNames[month - 1]

const calendar = useMemo(() => getMonthCalendar(year, month, firstDayOfWeek), [year, month, firstDayOfWeek])
function isDisabledTime(date: Date): boolean {
return !isDayInRange(date, { minDate, maxDate })
}
const calendarTable = (
<table className={styles.calendarTable}>
<thead>
<tr>
{weeknames.map(weekname => (
<th className={styles.calTableHeader} scope="col" key={weekname}>
{weekname}
</th>
))}
</tr>
</thead>
<tbody>
{calendar.map(week => (
<tr key={week[0].label}>
{week.map(date => (
<td key={`${date.month}${date.date}`}>
<button
type="button"
data-type="button"
aria-label={date.label}
aria-pressed={isDateEqual(date.instance, value)}
aria-current={date.isToday ? 'date' : 'false'}
title={date.label}
className={styles.calDateItem}
disabled={!date.isCurMonth || isDisabledTime(date.instance)}
onClick={() => onChange(date.instance)}
>
{date.date}
</button>
</td>
))}
</tr>
))}
</tbody>
</table>
)

const monthOptions: Option[] = Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
title: locale.monthNames[index],
selectable: isMonthInRange(year, index + 1, { minDate, maxDate }),
}))
const yearOptions: Option[] = Array.from({ length: 12 }, (_, index) => ({
value: year - 6 + index,
title: `${year - 6 + index}`,
selectable: isYearInRange(year - 6 + index, { minDate, maxDate }),
}))

const prevMonth = () => {
if (month > 1) {
setMonth(m => m - 1)
} else {
setYear(y => y - 1)
setMonth(12)
}
}
const nextMonth = () => {
if (month < 12) {
setMonth(m => m + 1)
} else {
setYear(y => y + 1)
setMonth(1)
}
}

const calendarHeader = (
<div className={styles.calendarHeader}>
<button type="button" aria-label="prev" title="prev" className={styles.calPrev} onClick={prevMonth}>
{'<'}
</button>
<div className={styles.calTitle}>
<button
type="button"
aria-label={monthName}
title={monthName}
aria-haspopup="true"
aria-controls="menu"
onClick={() => setStatus('month')}
>
{monthName}
</button>
<button
type="button"
aria-label={`${year}`}
title={`${year}`}
aria-haspopup="true"
aria-controls="menu"
onClick={() => setStatus('year')}
>
{year}
</button>
</div>
<button type="button" aria-label="next" title="next" className={styles.calNext} onClick={nextMonth}>
{'>'}
</button>
</div>
)

const onChangeMonth = useCallback(
(monthOptionItem: Option) => {
setMonth(monthOptionItem.value)
setStatus('date')
},
[setStatus, setMonth]
)
const onChangeYear = useCallback(
(yearOptionItem: Option) => {
setYear(yearOptionItem.value)
setStatus('month')
},
[setStatus, setYear]
)

return (
<div className={styles.calendar}>
{calendarHeader}
{status === 'date' && calendarTable}
{status === 'year' && <Selector options={yearOptions} onChange={onChangeYear} />}
{status === 'month' && <Selector options={monthOptions} onChange={onChangeMonth} />}
</div>
)
}

export default React.memo(
Calendar,
(prevProps, nextProps) =>
isDateEqual(prevProps.value, nextProps.value) &&
isDateEqual(prevProps.minDate, nextProps.minDate) &&
isDateEqual(prevProps.maxDate, nextProps.maxDate) &&
prevProps.firstDayOfWeek === nextProps.firstDayOfWeek &&
prevProps.onChange === nextProps.onChange
)
Loading