@jetbrains/ring-ui 7.0.106 → 7.0.108

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.
Files changed (38) hide show
  1. package/components/alert-service/alert-service.d.ts +2 -2
  2. package/components/alert-service/alert-service.js +2 -2
  3. package/components/button-group/button-group.js +14 -4
  4. package/components/date-picker/animate-date.d.ts +1 -0
  5. package/components/date-picker/animate-date.js +33 -0
  6. package/components/date-picker/consts.d.ts +21 -4
  7. package/components/date-picker/consts.js +16 -0
  8. package/components/date-picker/date-picker.css +27 -12
  9. package/components/date-picker/date-picker.d.ts +48 -1
  10. package/components/date-picker/date-picker.js +27 -1
  11. package/components/date-picker/date-popup.d.ts +3 -9
  12. package/components/date-picker/date-popup.js +27 -56
  13. package/components/date-picker/day.js +19 -15
  14. package/components/date-picker/month-names.js +28 -15
  15. package/components/date-picker/month-slider.d.ts +5 -20
  16. package/components/date-picker/month-slider.js +41 -43
  17. package/components/date-picker/month.d.ts +4 -0
  18. package/components/date-picker/month.js +34 -20
  19. package/components/date-picker/months.js +28 -81
  20. package/components/date-picker/scroll-arith.d.ts +35 -0
  21. package/components/date-picker/scroll-arith.js +65 -0
  22. package/components/date-picker/use-intersection-observer.d.ts +6 -0
  23. package/components/date-picker/use-intersection-observer.js +48 -0
  24. package/components/date-picker/use-scroll-behavior.d.ts +8 -0
  25. package/components/date-picker/use-scroll-behavior.js +94 -0
  26. package/components/date-picker/years.d.ts +1 -18
  27. package/components/date-picker/years.js +90 -70
  28. package/components/footer/footer.d.ts +1 -1
  29. package/components/global/dom.d.ts +1 -1
  30. package/components/global/dom.js +1 -1
  31. package/components/global/theme.js +2 -2
  32. package/components/heading/heading.d.ts +4 -4
  33. package/components/tabs/dumb-tabs.d.ts +1 -0
  34. package/components/tabs/dumb-tabs.js +2 -2
  35. package/components/util-stories.d.ts +1 -0
  36. package/components/util-stories.js +1 -0
  37. package/package.json +40 -39
  38. 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 end = endOfMonth(this.props.month);
16
- this.props.onScrollChange(end.getTime());
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 { month, locale } = this.props;
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 months = [];
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
- {months.map(month => (<MonthName key={+month} month={month} onScrollChange={props.onScrollChange} locale={locale}/>))}
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} pxToDate={pxToDate}/>
59
+ <MonthSlider {...props}/>
47
60
  </div>);
48
61
  }
@@ -1,20 +1,5 @@
1
- import { PureComponent } from 'react';
2
- import { type LinearFunction } from '../global/linear-function';
3
- import { type MonthsProps } from './consts';
4
- export interface MonthSliderProps extends MonthsProps {
5
- pxToDate: LinearFunction;
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 { PureComponent } from 'react';
1
+ import { useCallback, useMemo, useState } from 'react';
2
2
  import classNames from 'classnames';
3
3
  import { addYears } from 'date-fns/addYears';
4
- import { startOfDay } from 'date-fns/startOfDay';
5
- import { subYears } from 'date-fns/subYears';
6
- import linearFunction from '../global/linear-function';
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 COVERYEARS = 3;
10
- export default class MonthSlider extends PureComponent {
11
- state = {
12
- dragging: false,
13
- };
14
- componentDidUpdate(prevProps, prevState) {
15
- if (this.state.dragging && !prevState.dragging) {
16
- window.addEventListener('mousemove', this.onMouseMove);
17
- window.addEventListener('mouseup', this.onMouseUp);
18
- }
19
- else if (!this.state.dragging && prevState.dragging) {
20
- window.removeEventListener('mousemove', this.onMouseMove);
21
- window.removeEventListener('mouseup', this.onMouseUp);
22
- }
23
- }
24
- onMouseDown = () => {
25
- this.setState({ dragging: true });
26
- };
27
- onMouseUp = () => {
28
- this.setState({ dragging: false });
29
- };
30
- onMouseMove = (e) => {
31
- this.props.onScroll(linearFunction(0, Number(this.props.scrollDate), yearScrollSpeed).y(e.movementY));
32
- };
33
- render() {
34
- let year = subYears(startOfDay(this.props.scrollDate), 1);
35
- const years = [year];
36
- for (let i = 0; i <= COVERYEARS; i++) {
37
- year = addYears(year, 1);
38
- years.push(year);
39
- }
40
- const classes = classNames(styles.monthSlider, { [styles.dragging]: this.state.dragging });
41
- return (<div>
42
- {years.map(date => (<button type='button' key={+date} className={classes} style={{
43
- top: Math.floor(this.props.pxToDate.x(Number(date)) - units.cellSize),
44
- }} onMouseDown={this.onMouseDown}/>))}
45
- </div>);
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 { setDay } from 'date-fns/setDay';
3
+ import { getDaysInMonth, setDate } from 'date-fns';
4
+ import { useRef } from 'react';
6
5
  import Day from './day';
7
- import { WEEK, weekdays, shiftWeekArray, getWeekStartsOn, FIFTH_DAY } from './consts';
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 start = props.month;
11
- const end = endOfMonth(start);
12
- const { locale } = props;
13
- // pad with empty cells starting from last friday
14
- const weekday = getDay(start);
15
- const weekDays = shiftWeekArray(Object.values(weekdays), getWeekStartsOn(props.locale));
16
- const fifthDayOfWeek = weekDays[FIFTH_DAY];
17
- let day = setDay(start, weekday >= fifthDayOfWeek ? fifthDayOfWeek : fifthDayOfWeek - WEEK);
18
- const days = [];
19
- while (day < end) {
20
- days.push(day);
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 { subMonths } from 'date-fns/subMonths';
7
- import { endOfMonth } from 'date-fns/endOfMonth';
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 linearFunction from '../global/linear-function';
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
- const { unit, cellSize, calHeight } = units;
16
- const FridayToSunday = WEEK + weekdays.SU - weekdays.FR;
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
- // in milliseconds per pixel
29
- function scrollSpeed(date) {
30
- const monthStart = startOfMonth(date);
31
- const monthEnd = endOfMonth(date);
32
- return (Number(monthEnd) - Number(monthStart)) / monthHeight(monthStart);
33
- }
34
- const scrollSchedule = scheduleRAF();
35
- let dy = 0;
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 monthDate = scrollDate instanceof Date ? scrollDate : new Date(scrollDate);
39
- const monthStart = startOfMonth(monthDate);
40
- let month = subMonths(monthStart, MONTHSBACK);
41
- const months = [month];
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
+ }