@jetbrains/ring-ui 7.0.107 → 7.0.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/alert-service/alert-service.d.ts +2 -2
- package/components/alert-service/alert-service.js +2 -2
- package/components/button-group/button-group.js +14 -4
- package/components/checkbox/checkbox.d.ts +1 -0
- package/components/checkbox/checkbox.js +3 -2
- package/components/date-picker/animate-date.d.ts +1 -0
- package/components/date-picker/animate-date.js +33 -0
- package/components/date-picker/consts.d.ts +21 -4
- package/components/date-picker/consts.js +16 -0
- package/components/date-picker/date-picker.css +27 -12
- package/components/date-picker/date-picker.d.ts +48 -1
- package/components/date-picker/date-picker.js +27 -1
- package/components/date-picker/date-popup.d.ts +3 -9
- package/components/date-picker/date-popup.js +27 -56
- package/components/date-picker/day.js +19 -15
- package/components/date-picker/month-names.js +28 -15
- package/components/date-picker/month-slider.d.ts +5 -20
- package/components/date-picker/month-slider.js +41 -43
- package/components/date-picker/month.d.ts +4 -0
- package/components/date-picker/month.js +34 -20
- package/components/date-picker/months.js +28 -81
- package/components/date-picker/scroll-arith.d.ts +35 -0
- package/components/date-picker/scroll-arith.js +65 -0
- package/components/date-picker/use-intersection-observer.d.ts +6 -0
- package/components/date-picker/use-intersection-observer.js +48 -0
- package/components/date-picker/use-scroll-behavior.d.ts +8 -0
- package/components/date-picker/use-scroll-behavior.js +94 -0
- package/components/date-picker/years.d.ts +1 -18
- package/components/date-picker/years.js +90 -70
- package/components/footer/footer.d.ts +1 -1
- package/components/global/dom.d.ts +1 -1
- package/components/global/dom.js +1 -1
- package/components/global/theme.js +2 -2
- package/components/heading/heading.d.ts +4 -4
- package/components/util-stories.d.ts +1 -0
- package/components/util-stories.js +1 -0
- package/package.json +40 -39
- package/typings.d.ts +5 -0
|
@@ -1,22 +1,39 @@
|
|
|
1
1
|
import { PureComponent } from 'react';
|
|
2
2
|
import classNames from 'classnames';
|
|
3
|
-
import { endOfMonth } from 'date-fns/endOfMonth';
|
|
4
3
|
import { format } from 'date-fns/format';
|
|
5
4
|
import { isThisMonth } from 'date-fns/isThisMonth';
|
|
6
|
-
import { set } from 'date-fns/set';
|
|
7
|
-
import { startOfDay } from 'date-fns/startOfDay';
|
|
8
5
|
import { startOfYear } from 'date-fns/startOfYear';
|
|
6
|
+
import { addMonths, getYear, startOfMonth } from 'date-fns';
|
|
9
7
|
import linearFunction from '../global/linear-function';
|
|
10
8
|
import MonthSlider from './month-slider';
|
|
11
9
|
import { YEAR, MIDDLE_DAY, yearScrollSpeed } from './consts';
|
|
10
|
+
import { animateDate } from './animate-date';
|
|
12
11
|
import styles from './date-picker.css';
|
|
13
12
|
class MonthName extends PureComponent {
|
|
13
|
+
componentWillUnmount() {
|
|
14
|
+
this.animationCleanup?.();
|
|
15
|
+
}
|
|
16
|
+
animationCleanup = null;
|
|
14
17
|
handleClick = () => {
|
|
15
|
-
const
|
|
16
|
-
|
|
18
|
+
const start = startOfMonth(this.getMiddleDay(this.props.monthIndex));
|
|
19
|
+
const nextStart = addMonths(start, 1);
|
|
20
|
+
// Because the space between months belongs to the next month, we position the target month a bit to the top
|
|
21
|
+
// eslint-disable-next-line no-magic-numbers
|
|
22
|
+
const targetDate = new Date(0.4 * Number(start) + 0.6 * Number(nextStart));
|
|
23
|
+
this.animationCleanup?.();
|
|
24
|
+
this.animationCleanup = animateDate(this.props.scrollDate.date, targetDate, date => {
|
|
25
|
+
this.props.setScrollDate({
|
|
26
|
+
date,
|
|
27
|
+
source: 'other',
|
|
28
|
+
});
|
|
29
|
+
});
|
|
17
30
|
};
|
|
31
|
+
getMiddleDay(monthIndex) {
|
|
32
|
+
return new Date(getYear(this.props.scrollDate.date), monthIndex, MIDDLE_DAY);
|
|
33
|
+
}
|
|
18
34
|
render() {
|
|
19
|
-
const {
|
|
35
|
+
const { monthIndex, locale } = this.props;
|
|
36
|
+
const month = this.getMiddleDay(monthIndex);
|
|
20
37
|
return (<button type='button' className={classNames(styles.monthName, {
|
|
21
38
|
[styles.today]: isThisMonth(month),
|
|
22
39
|
})} onClick={this.handleClick}>
|
|
@@ -24,25 +41,21 @@ class MonthName extends PureComponent {
|
|
|
24
41
|
</button>);
|
|
25
42
|
}
|
|
26
43
|
}
|
|
44
|
+
const monthsIndices = Array.from({ length: YEAR }, (_, i) => i);
|
|
27
45
|
export default function MonthNames(props) {
|
|
28
46
|
const { scrollDate, locale } = props;
|
|
29
|
-
const
|
|
30
|
-
for (let i = 0; i < YEAR; i++) {
|
|
31
|
-
const middleDay = set(scrollDate, { month: i, date: MIDDLE_DAY });
|
|
32
|
-
months.push(startOfDay(middleDay));
|
|
33
|
-
}
|
|
34
|
-
const pxToDate = linearFunction(0, Number(startOfYear(scrollDate)), yearScrollSpeed);
|
|
47
|
+
const pxToDate = linearFunction(0, Number(startOfYear(scrollDate.date)), yearScrollSpeed);
|
|
35
48
|
let top = 0;
|
|
36
49
|
let bottom = 0;
|
|
37
50
|
if (props.currentRange) {
|
|
38
51
|
[top, bottom] = props.currentRange.map(date => Math.floor(pxToDate.x(Number(date))));
|
|
39
52
|
}
|
|
40
|
-
return (<div className={styles.monthNames}>
|
|
41
|
-
{
|
|
53
|
+
return (<div className={styles.monthNames} data-test='ring-date-popup--month-names'>
|
|
54
|
+
{monthsIndices.map(monthIndex => (<MonthName key={monthIndex} scrollDate={scrollDate} monthIndex={monthIndex} setScrollDate={props.setScrollDate} locale={locale}/>))}
|
|
42
55
|
{props.currentRange && (<div className={styles.range} style={{
|
|
43
56
|
top: top - 1,
|
|
44
57
|
height: bottom + 1 - (top - 1),
|
|
45
58
|
}}/>)}
|
|
46
|
-
<MonthSlider {...props}
|
|
59
|
+
<MonthSlider {...props}/>
|
|
47
60
|
</div>);
|
|
48
61
|
}
|
|
@@ -1,20 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
7
|
-
interface MonthSliderState {
|
|
8
|
-
dragging: boolean;
|
|
9
|
-
}
|
|
10
|
-
export default class MonthSlider extends PureComponent<MonthSliderProps> {
|
|
11
|
-
state: {
|
|
12
|
-
dragging: boolean;
|
|
13
|
-
};
|
|
14
|
-
componentDidUpdate(prevProps: MonthSliderProps, prevState: MonthSliderState): void;
|
|
15
|
-
onMouseDown: () => void;
|
|
16
|
-
onMouseUp: () => void;
|
|
17
|
-
onMouseMove: (e: MouseEvent) => void;
|
|
18
|
-
render(): import("react").JSX.Element;
|
|
19
|
-
}
|
|
20
|
-
export {};
|
|
1
|
+
import { type ScrollDate } from './consts';
|
|
2
|
+
export default function MonthSlider({ scrollDate, setScrollDate, }: {
|
|
3
|
+
scrollDate: ScrollDate;
|
|
4
|
+
setScrollDate: (scrollDate: ScrollDate) => void;
|
|
5
|
+
}): import("react").JSX.Element;
|
|
@@ -1,47 +1,45 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
2
|
import classNames from 'classnames';
|
|
3
3
|
import { addYears } from 'date-fns/addYears';
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import units, { yearScrollSpeed } from './consts';
|
|
4
|
+
import { startOfYear } from 'date-fns';
|
|
5
|
+
import units from './consts';
|
|
6
|
+
import scheduleRAF from '../global/schedule-raf';
|
|
8
7
|
import styles from './date-picker.css';
|
|
9
|
-
const
|
|
10
|
-
export default
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
8
|
+
const scheduleScroll = scheduleRAF();
|
|
9
|
+
export default function MonthSlider({ scrollDate, setScrollDate, }) {
|
|
10
|
+
const [dragStart, setDragStart] = useState(null);
|
|
11
|
+
const onPointerDown = useCallback((e) => {
|
|
12
|
+
const pointerId = e.pointerId;
|
|
13
|
+
setDragStart({ y: e.pageY, scrollDate: Number(scrollDate.date), pointerId });
|
|
14
|
+
e.currentTarget.setPointerCapture(pointerId);
|
|
15
|
+
}, [scrollDate]);
|
|
16
|
+
const onPointerMove = useCallback((e) => {
|
|
17
|
+
scheduleScroll(() => {
|
|
18
|
+
if (!dragStart)
|
|
19
|
+
return;
|
|
20
|
+
const { y: startY, scrollDate: startDate } = dragStart;
|
|
21
|
+
const yearFraction = (e.pageY - startY) / units.calHeight;
|
|
22
|
+
const startDatePlusOneYear = Number(addYears(new Date(startDate), 1));
|
|
23
|
+
const newScrollDate = startDate + yearFraction * (startDatePlusOneYear - startDate);
|
|
24
|
+
setScrollDate({ date: newScrollDate, source: 'other' });
|
|
25
|
+
});
|
|
26
|
+
}, [setScrollDate, dragStart]);
|
|
27
|
+
const onPointerUp = useCallback((e) => {
|
|
28
|
+
if (!dragStart)
|
|
29
|
+
return;
|
|
30
|
+
const { pointerId } = dragStart;
|
|
31
|
+
setDragStart(null);
|
|
32
|
+
e.currentTarget.releasePointerCapture?.(pointerId);
|
|
33
|
+
}, [dragStart]);
|
|
34
|
+
const offsets = useMemo(() => {
|
|
35
|
+
const yearStart = startOfYear(scrollDate.date);
|
|
36
|
+
const yearEnd = addYears(yearStart, 1);
|
|
37
|
+
const yearFraction = (Number(scrollDate.date) - Number(yearStart)) / (Number(yearEnd) - Number(yearStart));
|
|
38
|
+
return [yearFraction - 1, yearFraction, yearFraction + 1];
|
|
39
|
+
}, [scrollDate]);
|
|
40
|
+
return (<>
|
|
41
|
+
{offsets.map(offset => (<button type='button' key={Math.floor(offset)} className={classNames(styles.monthSlider, dragStart && styles.dragging)} style={{
|
|
42
|
+
top: offset * units.calHeight - units.cellSize,
|
|
43
|
+
}} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}/>))}
|
|
44
|
+
</>);
|
|
47
45
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { type Locale } from 'date-fns';
|
|
1
2
|
import { type MonthsProps } from './consts';
|
|
3
|
+
import { type IntersectionObserverHandle } from './use-intersection-observer';
|
|
2
4
|
export interface MonthProps extends MonthsProps {
|
|
3
5
|
month: Date;
|
|
6
|
+
intersectionObserverHandle: IntersectionObserverHandle | null;
|
|
4
7
|
}
|
|
5
8
|
export default function Month(props: MonthProps): import("react").JSX.Element;
|
|
9
|
+
export declare function getMonthHeight(monthStart: Date | number, locale: Locale | undefined): number;
|
|
@@ -1,27 +1,41 @@
|
|
|
1
|
-
import { addDays } from 'date-fns/addDays';
|
|
2
|
-
import { endOfMonth } from 'date-fns/endOfMonth';
|
|
3
1
|
import { format } from 'date-fns/format';
|
|
4
2
|
import { getDay } from 'date-fns/getDay';
|
|
5
|
-
import {
|
|
3
|
+
import { getDaysInMonth, setDate } from 'date-fns';
|
|
4
|
+
import { useRef } from 'react';
|
|
6
5
|
import Day from './day';
|
|
7
|
-
import { WEEK,
|
|
6
|
+
import units, { WEEK, getWeekStartsOn } from './consts';
|
|
7
|
+
import { useVisibility } from './use-intersection-observer';
|
|
8
8
|
import styles from './date-picker.css';
|
|
9
9
|
export default function Month(props) {
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
day = addDays(day, 1);
|
|
22
|
-
}
|
|
23
|
-
return (<div className={styles.month}>
|
|
24
|
-
<span className={styles.monthTitle}>{format(props.month, 'LLLL', { locale })}</span>
|
|
25
|
-
{days.map(date => (<Day {...props} day={date} empty={date < start} key={+date}/>))}
|
|
10
|
+
const { month, locale, intersectionObserverHandle } = props;
|
|
11
|
+
const containerRef = useRef(null);
|
|
12
|
+
const visible = useVisibility(intersectionObserverHandle, containerRef);
|
|
13
|
+
return (<div className={styles.month} ref={containerRef} style={visible ? {} : { height: getMonthHeight(month, locale) }}>
|
|
14
|
+
{visible && (<>
|
|
15
|
+
<span className={styles.monthTitle}>{format(month, 'LLLL', { locale })}</span>
|
|
16
|
+
|
|
17
|
+
{Array.from({ length: getPaddingCellsNum(month, locale) }, (_, i) => (<Day {...props} day={new Date(0)} empty key={`e_${i}`}/>))}
|
|
18
|
+
|
|
19
|
+
{Array.from({ length: getDaysInMonth(month) }, (_, i) => (<Day {...props} day={setDate(month, i + 1)} empty={false} key={i}/>))}
|
|
20
|
+
</>)}
|
|
26
21
|
</div>);
|
|
27
22
|
}
|
|
23
|
+
const cellsPerMonthName = 4;
|
|
24
|
+
/**
|
|
25
|
+
* Between the month name and the first month day
|
|
26
|
+
*/
|
|
27
|
+
function getPaddingCellsNum(monthStart, locale) {
|
|
28
|
+
const monthStartWeekdaySundayBased = getDay(monthStart);
|
|
29
|
+
const weekStartDay = getWeekStartsOn(locale);
|
|
30
|
+
const monthStartWeekday = (monthStartWeekdaySundayBased - weekStartDay + WEEK) % WEEK;
|
|
31
|
+
if (monthStartWeekday >= cellsPerMonthName)
|
|
32
|
+
return monthStartWeekday - cellsPerMonthName;
|
|
33
|
+
const upperPadding = WEEK - cellsPerMonthName;
|
|
34
|
+
const lowerPadding = monthStartWeekday;
|
|
35
|
+
return upperPadding + lowerPadding;
|
|
36
|
+
}
|
|
37
|
+
export function getMonthHeight(monthStart, locale) {
|
|
38
|
+
const totalCells = cellsPerMonthName + getPaddingCellsNum(monthStart, locale) + getDaysInMonth(new Date(monthStart));
|
|
39
|
+
const monthLines = Math.ceil(totalCells / WEEK);
|
|
40
|
+
return monthLines * units.cellSize;
|
|
41
|
+
}
|
|
@@ -1,89 +1,36 @@
|
|
|
1
|
-
import { useEffect, useRef } from 'react';
|
|
2
1
|
import { addMonths } from 'date-fns/addMonths';
|
|
3
|
-
import { getDay } from 'date-fns/getDay';
|
|
4
|
-
import { getDaysInMonth } from 'date-fns/getDaysInMonth';
|
|
5
2
|
import { startOfMonth } from 'date-fns/startOfMonth';
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
3
|
+
import Month, { getMonthHeight } from './month';
|
|
4
|
+
import units from './consts';
|
|
5
|
+
import { ScrollArith } from './scroll-arith';
|
|
6
|
+
import { useScrollBehavior } from './use-scroll-behavior';
|
|
8
7
|
import scheduleRAF from '../global/schedule-raf';
|
|
9
|
-
import
|
|
10
|
-
import useEventCallback from '../global/use-event-callback';
|
|
11
|
-
import Month from './month';
|
|
12
|
-
import MonthNames from './month-names';
|
|
13
|
-
import units, { DOUBLE, HALF, WEEK, weekdays } from './consts';
|
|
8
|
+
import { useIntersectionObserver } from './use-intersection-observer';
|
|
14
9
|
import styles from './date-picker.css';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const FIVELINES = 31;
|
|
18
|
-
const TALLMONTH = 6;
|
|
19
|
-
const SHORTMONTH = 5;
|
|
20
|
-
const PADDING = 2;
|
|
21
|
-
const MONTHSBACK = 2;
|
|
22
|
-
function monthHeight(date) {
|
|
23
|
-
const monthStart = startOfMonth(date);
|
|
24
|
-
const daysSinceLastFriday = (getDay(monthStart) + FridayToSunday) % WEEK;
|
|
25
|
-
const monthLines = daysSinceLastFriday + getDaysInMonth(monthStart) > FIVELINES ? TALLMONTH : SHORTMONTH;
|
|
26
|
-
return monthLines * cellSize + unit * PADDING;
|
|
10
|
+
function getMonthHeightWithMargin(date, locale) {
|
|
11
|
+
return units.unit * 2 + getMonthHeight(date, locale);
|
|
27
12
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
const
|
|
35
|
-
|
|
13
|
+
const scrollArith = new ScrollArith({
|
|
14
|
+
itemsAround: 60,
|
|
15
|
+
floorToItem: startOfMonth,
|
|
16
|
+
shiftItems: addMonths,
|
|
17
|
+
getItemHeight: (item, locale) => getMonthHeightWithMargin(item, locale),
|
|
18
|
+
});
|
|
19
|
+
const scheduleScroll = scheduleRAF();
|
|
20
|
+
/**
|
|
21
|
+
* In range selection (start/end dates across different months), the gap between months
|
|
22
|
+
* is correctly background-filled only when both months are reported as visible.
|
|
23
|
+
*
|
|
24
|
+
* To avoid an unpainted gap at the viewport boundary, the next month must be reported
|
|
25
|
+
* as visible slightly before it actually enters the viewport. We achieve this by
|
|
26
|
+
* extending the IntersectionObserver scrollMargin.
|
|
27
|
+
*/
|
|
28
|
+
const intersectionObserverScrollMargin = units.cellSize * 2;
|
|
36
29
|
export default function Months(props) {
|
|
37
|
-
const { scrollDate } = props;
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
for (let i = 0; i < MONTHSBACK * DOUBLE; i++) {
|
|
43
|
-
month = addMonths(month, 1);
|
|
44
|
-
months.push(month);
|
|
45
|
-
}
|
|
46
|
-
const currentSpeed = scrollSpeed(scrollDate);
|
|
47
|
-
const pxToDate = linearFunction(0, Number(scrollDate), currentSpeed);
|
|
48
|
-
const offset = pxToDate.x(Number(monthStart)); // is a negative number
|
|
49
|
-
const bottomOffset = monthHeight(scrollDate) + offset;
|
|
50
|
-
const componentRef = useRef(null);
|
|
51
|
-
const handleWheel = useEventCallback((e) => {
|
|
52
|
-
e.preventDefault();
|
|
53
|
-
dy += e.deltaY;
|
|
54
|
-
scrollSchedule(() => {
|
|
55
|
-
let date;
|
|
56
|
-
// adjust scroll speed to prevent glitches
|
|
57
|
-
if (dy < offset) {
|
|
58
|
-
date = pxToDate.y(offset) + (dy - offset) * scrollSpeed(months[1]);
|
|
59
|
-
}
|
|
60
|
-
else if (dy > bottomOffset) {
|
|
61
|
-
date = pxToDate.y(bottomOffset) + (dy - bottomOffset) * scrollSpeed(months[MONTHSBACK + 1]);
|
|
62
|
-
}
|
|
63
|
-
else {
|
|
64
|
-
date = pxToDate.y(dy);
|
|
65
|
-
}
|
|
66
|
-
props.onScroll(date);
|
|
67
|
-
dy = 0;
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
const current = componentRef.current;
|
|
72
|
-
if (current) {
|
|
73
|
-
current.addEventListener('wheel', handleWheel, { passive: false });
|
|
74
|
-
}
|
|
75
|
-
return () => {
|
|
76
|
-
if (current) {
|
|
77
|
-
current.removeEventListener('wheel', handleWheel);
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
}, [handleWheel]);
|
|
81
|
-
return (<div className={styles.months} ref={componentRef}>
|
|
82
|
-
<div style={{
|
|
83
|
-
top: Math.floor(calHeight * HALF - monthHeight(months[0]) - monthHeight(months[1]) + offset),
|
|
84
|
-
}} className={styles.days}>
|
|
85
|
-
{months.map(date => (<Month {...props} month={date} key={+date}/>))}
|
|
86
|
-
</div>
|
|
87
|
-
<MonthNames {...props}/>
|
|
30
|
+
const { scrollDate, setScrollDate, locale } = props;
|
|
31
|
+
const { containerRef, items } = useScrollBehavior(scrollDate, setScrollDate, locale, 'monthsScroll', scrollArith, scheduleScroll);
|
|
32
|
+
const intersectionObserverHandle = useIntersectionObserver(containerRef, intersectionObserverScrollMargin);
|
|
33
|
+
return (<div className={styles.months} ref={containerRef} data-test='ring-date-popup--months'>
|
|
34
|
+
{items.map(month => (<Month {...props} month={month} key={+month} intersectionObserverHandle={intersectionObserverHandle}/>))}
|
|
88
35
|
</div>);
|
|
89
36
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Locale } from 'date-fns';
|
|
2
|
+
/**
|
|
3
|
+
* Scroll math helper for month/year scrollers.
|
|
4
|
+
*
|
|
5
|
+
* An "item" is a floor-boundary date representing the start of a visible period
|
|
6
|
+
* (e.g. start of a month or start of a year).
|
|
7
|
+
*/
|
|
8
|
+
export declare class ScrollArith {
|
|
9
|
+
private itemsAround;
|
|
10
|
+
private floorToItem;
|
|
11
|
+
private shiftItem;
|
|
12
|
+
private getItemHeight;
|
|
13
|
+
constructor(params: {
|
|
14
|
+
itemsAround: number;
|
|
15
|
+
floorToItem: (date: Date) => Date;
|
|
16
|
+
shiftItems: (date: Date, delta: number) => Date;
|
|
17
|
+
getItemHeight: (item: Date, locale: Locale | undefined) => number;
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* Builds a symmetric list of items centered around the given scrollDate.
|
|
21
|
+
*/
|
|
22
|
+
getItems(scrollDate: Date | number): Date[];
|
|
23
|
+
/**
|
|
24
|
+
* Computes the scroll offset which places the `scrollDate` at the vertical center.
|
|
25
|
+
*/
|
|
26
|
+
getScrollTop(items: Date[], scrollDate: Date | number, locale: Locale | undefined): number;
|
|
27
|
+
getItemsAndScrollTop(scrollDate: Date | number, locale: Locale | undefined): {
|
|
28
|
+
newItems: Date[];
|
|
29
|
+
newScrollTop: number;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Returns the date currently located in the vertical center of the calendar.
|
|
33
|
+
*/
|
|
34
|
+
getScrollDate(items: Date[], scrollTop: number, locale: Locale | undefined): Date;
|
|
35
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import units, { HALF } from './consts';
|
|
2
|
+
/**
|
|
3
|
+
* Scroll math helper for month/year scrollers.
|
|
4
|
+
*
|
|
5
|
+
* An "item" is a floor-boundary date representing the start of a visible period
|
|
6
|
+
* (e.g. start of a month or start of a year).
|
|
7
|
+
*/
|
|
8
|
+
export class ScrollArith {
|
|
9
|
+
itemsAround;
|
|
10
|
+
floorToItem;
|
|
11
|
+
shiftItem;
|
|
12
|
+
getItemHeight;
|
|
13
|
+
constructor(params) {
|
|
14
|
+
this.itemsAround = params.itemsAround;
|
|
15
|
+
this.floorToItem = params.floorToItem;
|
|
16
|
+
this.shiftItem = params.shiftItems;
|
|
17
|
+
this.getItemHeight = params.getItemHeight;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Builds a symmetric list of items centered around the given scrollDate.
|
|
21
|
+
*/
|
|
22
|
+
getItems(scrollDate) {
|
|
23
|
+
const centerItem = this.floorToItem(new Date(scrollDate));
|
|
24
|
+
return Array.from({ length: 1 + this.itemsAround * 2 }, (_, index) => this.shiftItem(centerItem, index - this.itemsAround));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Computes the scroll offset which places the `scrollDate` at the vertical center.
|
|
28
|
+
*/
|
|
29
|
+
getScrollTop(items, scrollDate, locale) {
|
|
30
|
+
const item = this.floorToItem(new Date(scrollDate));
|
|
31
|
+
const nextItem = this.shiftItem(item, 1);
|
|
32
|
+
let index = items.findIndex(it => Number(it) === Number(item));
|
|
33
|
+
if (index === -1) {
|
|
34
|
+
index = Number(item) < Number(items[0]) ? 0 : items.length - 1;
|
|
35
|
+
}
|
|
36
|
+
const itemFraction = (Number(scrollDate) - Number(item)) / (Number(nextItem) - Number(item));
|
|
37
|
+
const offsetWithinItem = itemFraction * this.getItemHeight(item, locale);
|
|
38
|
+
const heightBeforeItem = items
|
|
39
|
+
.slice(0, index)
|
|
40
|
+
.reduce((totalHeight, it) => totalHeight + this.getItemHeight(it, locale), 0);
|
|
41
|
+
return heightBeforeItem + offsetWithinItem - units.calHeight * HALF;
|
|
42
|
+
}
|
|
43
|
+
getItemsAndScrollTop(scrollDate, locale) {
|
|
44
|
+
const newItems = this.getItems(scrollDate);
|
|
45
|
+
const newScrollTop = this.getScrollTop(newItems, scrollDate, locale);
|
|
46
|
+
return { newItems, newScrollTop };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Returns the date currently located in the vertical center of the calendar.
|
|
50
|
+
*/
|
|
51
|
+
getScrollDate(items, scrollTop, locale) {
|
|
52
|
+
let heightBeforeItem = 0;
|
|
53
|
+
for (const item of items) {
|
|
54
|
+
const itemHeight = this.getItemHeight(item, locale);
|
|
55
|
+
const offsetWithinItem = scrollTop - heightBeforeItem + units.calHeight * HALF;
|
|
56
|
+
if (offsetWithinItem < itemHeight) {
|
|
57
|
+
const itemFraction = offsetWithinItem / itemHeight;
|
|
58
|
+
const nextItem = this.shiftItem(item, 1);
|
|
59
|
+
return new Date(Number(item) + itemFraction * (Number(nextItem) - Number(item)));
|
|
60
|
+
}
|
|
61
|
+
heightBeforeItem += itemHeight;
|
|
62
|
+
}
|
|
63
|
+
return items[items.length - 1];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
export declare function useIntersectionObserver(containerRef: RefObject<HTMLElement | null>, scrollMargin?: number): IntersectionObserverHandle | null;
|
|
3
|
+
export interface IntersectionObserverHandle {
|
|
4
|
+
observeVisibility(element: Element, setVisible: (isVisible: boolean) => void): () => void;
|
|
5
|
+
}
|
|
6
|
+
export declare function useVisibility(handle: IntersectionObserverHandle | null, elementRef: RefObject<Element | null>): boolean;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
export function useIntersectionObserver(containerRef, scrollMargin = 0) {
|
|
3
|
+
const [handle, setHandle] = useState(null);
|
|
4
|
+
useEffect(() => {
|
|
5
|
+
const container = containerRef.current;
|
|
6
|
+
if (!container)
|
|
7
|
+
return;
|
|
8
|
+
const elementToSetVisible = new Map();
|
|
9
|
+
const observer = new IntersectionObserver(entries => {
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
const setVisible = elementToSetVisible.get(entry.target);
|
|
12
|
+
setVisible?.(entry.isIntersecting);
|
|
13
|
+
}
|
|
14
|
+
}, {
|
|
15
|
+
root: container,
|
|
16
|
+
...(scrollMargin
|
|
17
|
+
? {
|
|
18
|
+
scrollMargin: `${scrollMargin}px`,
|
|
19
|
+
}
|
|
20
|
+
: {}),
|
|
21
|
+
});
|
|
22
|
+
setHandle({
|
|
23
|
+
observeVisibility(element, setVisible) {
|
|
24
|
+
elementToSetVisible.set(element, setVisible);
|
|
25
|
+
observer.observe(element);
|
|
26
|
+
return () => {
|
|
27
|
+
elementToSetVisible.delete(element);
|
|
28
|
+
observer.unobserve(element);
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
return () => {
|
|
33
|
+
observer.disconnect();
|
|
34
|
+
setHandle(null);
|
|
35
|
+
};
|
|
36
|
+
}, [containerRef, scrollMargin]);
|
|
37
|
+
return handle;
|
|
38
|
+
}
|
|
39
|
+
export function useVisibility(handle, elementRef) {
|
|
40
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const element = elementRef.current;
|
|
43
|
+
if (!element || !handle)
|
|
44
|
+
return;
|
|
45
|
+
return handle.observeVisibility(element, setIsVisible);
|
|
46
|
+
}, [handle, elementRef, setIsVisible]);
|
|
47
|
+
return isVisible;
|
|
48
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Locale } from 'date-fns';
|
|
2
|
+
import { type CalendarProps, type ScrollDate } from './consts';
|
|
3
|
+
import { type ScrollArith } from './scroll-arith';
|
|
4
|
+
import type scheduleRAF from '../global/schedule-raf';
|
|
5
|
+
export declare function useScrollBehavior(scrollDate: ScrollDate, onContainerScroll: CalendarProps['setScrollDate'], locale: Locale | undefined, selfScrollDateSource: ScrollDate['source'], arith: ScrollArith, scheduleScroll: ReturnType<typeof scheduleRAF>): {
|
|
6
|
+
containerRef: import("react").RefObject<HTMLDivElement | null>;
|
|
7
|
+
items: Date[];
|
|
8
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import units, { isSafariOnIPhone, scrollerReRenderDelayIPhone } from './consts';
|
|
3
|
+
import useEventCallback from '../global/use-event-callback';
|
|
4
|
+
export function useScrollBehavior(scrollDate, onContainerScroll, locale, selfScrollDateSource, arith, scheduleScroll) {
|
|
5
|
+
const [items, setItems] = useState(() => arith.getItems(scrollDate.date));
|
|
6
|
+
const [scrollTop, setScrollTop] = useState(() => arith.getScrollTop(items, scrollDate.date, locale));
|
|
7
|
+
const containerRef = useRef(null);
|
|
8
|
+
const syncSelfState = useEventCallback((newScrollDate) => {
|
|
9
|
+
const newScrollTopOnExistingItems = arith.getScrollTop(items, newScrollDate, locale);
|
|
10
|
+
if (isNearEdge(newScrollTopOnExistingItems, containerRef.current)) {
|
|
11
|
+
const { newItems, newScrollTop } = arith.getItemsAndScrollTop(newScrollDate, locale);
|
|
12
|
+
setItems(newItems);
|
|
13
|
+
setScrollTop(newScrollTop);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
setScrollTop(newScrollTopOnExistingItems);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
const didMountRef = useRef(false);
|
|
20
|
+
useEffect(function onExternalScrollDateChange() {
|
|
21
|
+
if (!didMountRef.current) {
|
|
22
|
+
didMountRef.current = true;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const container = containerRef.current;
|
|
26
|
+
if (!container)
|
|
27
|
+
return;
|
|
28
|
+
if (scrollDate.source === selfScrollDateSource)
|
|
29
|
+
return;
|
|
30
|
+
syncSelfState(scrollDate.date);
|
|
31
|
+
}, [scrollDate, selfScrollDateSource, syncSelfState]);
|
|
32
|
+
const ignoreNextScrollEventRef = useRef(true);
|
|
33
|
+
useLayoutEffect(function setContainerScrollFromState() {
|
|
34
|
+
const container = containerRef.current;
|
|
35
|
+
if (!container)
|
|
36
|
+
return;
|
|
37
|
+
ignoreNextScrollEventRef.current = true;
|
|
38
|
+
container.scrollTop = scrollTop;
|
|
39
|
+
}, [items, scrollTop]);
|
|
40
|
+
const updateStateTimerRef = useRef(null);
|
|
41
|
+
const handleScroll = useEventCallback(() => {
|
|
42
|
+
scheduleScroll(() => {
|
|
43
|
+
if (updateStateTimerRef.current != null) {
|
|
44
|
+
window.clearTimeout(updateStateTimerRef.current);
|
|
45
|
+
updateStateTimerRef.current = null;
|
|
46
|
+
}
|
|
47
|
+
const container = containerRef.current;
|
|
48
|
+
if (!container)
|
|
49
|
+
return;
|
|
50
|
+
if (ignoreNextScrollEventRef.current) {
|
|
51
|
+
ignoreNextScrollEventRef.current = false;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const currentScrollTop = container.scrollTop;
|
|
55
|
+
const newScrollDate = arith.getScrollDate(items, currentScrollTop, locale);
|
|
56
|
+
onContainerScroll({ date: newScrollDate, source: selfScrollDateSource });
|
|
57
|
+
if (isNearEdge(currentScrollTop, container)) {
|
|
58
|
+
function updateState() {
|
|
59
|
+
const { newItems, newScrollTop } = arith.getItemsAndScrollTop(newScrollDate, locale);
|
|
60
|
+
setItems(newItems);
|
|
61
|
+
setScrollTop(newScrollTop);
|
|
62
|
+
updateStateTimerRef.current = null;
|
|
63
|
+
}
|
|
64
|
+
if (isSafariOnIPhone) {
|
|
65
|
+
// `scrollend` is too young so far
|
|
66
|
+
updateStateTimerRef.current = window.setTimeout(updateState, scrollerReRenderDelayIPhone);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
updateState();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const container = containerRef.current;
|
|
76
|
+
if (!container)
|
|
77
|
+
return;
|
|
78
|
+
container.addEventListener('scroll', handleScroll);
|
|
79
|
+
return () => {
|
|
80
|
+
container.removeEventListener('scroll', handleScroll);
|
|
81
|
+
if (updateStateTimerRef.current != null) {
|
|
82
|
+
window.clearTimeout(updateStateTimerRef.current);
|
|
83
|
+
updateStateTimerRef.current = null;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}, [handleScroll]);
|
|
87
|
+
return { containerRef, items };
|
|
88
|
+
}
|
|
89
|
+
function isNearEdge(scrollTop, container) {
|
|
90
|
+
const scrollHeight = container.scrollHeight;
|
|
91
|
+
// eslint-disable-next-line no-magic-numbers
|
|
92
|
+
const scrollDistanceNearEdge = isSafariOnIPhone ? 5 : units.calHeight * 2;
|
|
93
|
+
return scrollTop <= scrollDistanceNearEdge || scrollHeight - units.calHeight - scrollTop <= scrollDistanceNearEdge;
|
|
94
|
+
}
|