Skip to content

Commit

Permalink
feat(mobile): new journal date-picker
Browse files Browse the repository at this point in the history
  • Loading branch information
CatsJuice authored and EYHN committed Nov 11, 2024
1 parent 7bdad2d commit 7903bed
Show file tree
Hide file tree
Showing 15 changed files with 828 additions and 47 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const CELL_HEIGHT = 34;
export const TOTAL_WEEKS = 6;
export const ROWS_GAP = 4;

export const MONTH_VIEW_HEIGHT =
TOTAL_WEEKS * CELL_HEIGHT + (TOTAL_WEEKS - 1) * ROWS_GAP;
export const WEEK_VIEW_HEIGHT = CELL_HEIGHT;

export const HORIZONTAL_SWIPE_THRESHOLD = 4 * CELL_HEIGHT;

export const DATE_FORMAT = 'YYYY-MM-DD';
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createContext } from 'react';

export const JournalDatePickerContext = createContext<{
width: number;
/**
* Is used to determine the current date, not same as selected,
* `is-current-month` is based on cursor
*/
cursor: string;
setCursor: (date: string) => void;
selected: string;
onSelect: (date: string) => void;
withDotDates: Set<string | null | undefined>;
}>({
width: window.innerWidth,
cursor: '',
setCursor: () => {},
selected: '',
onSelect: () => {},
withDotDates: new Set(),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { bodyRegular } from '@toeverything/theme/typography';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';

export const dayCell = style([
bodyRegular,
{
position: 'relative',
height: 34,
minWidth: 34,
padding: 4,
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',

selectors: {
'&[data-is-today=true]': {},
'&[data-is-current-month=false]': {
color: cssVarV2('text/disable'),
},
'&[data-is-selected=true]': {
background: cssVarV2('button/primary'),
color: cssVarV2('button/pureWhiteText'),
fontWeight: 600,
},
},
},
]);

export const dot = style({
position: 'absolute',
width: 3,
height: 3,
borderRadius: 3,
background: cssVarV2('button/primary'),
bottom: 3,
left: '50%',
transform: 'translateX(-50%)',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import dayjs from 'dayjs';
import { memo, useContext, useMemo } from 'react';

import { DATE_FORMAT } from './constants';
import { JournalDatePickerContext } from './context';
import { dayCell, dot } from './day-cell.css';

export interface DayCellProps {
date: string;
}

export const DayCell = memo(function DayCell({ date }: DayCellProps) {
const { selected, onSelect, cursor, withDotDates } = useContext(
JournalDatePickerContext
);

const dayjsObj = useMemo(() => dayjs(date), [date]);

const isToday = dayjsObj.isSame(dayjs(), 'day');
const isSelected = dayjsObj.isSame(dayjs(selected), 'day');
const isCurrentMonth = dayjsObj.isSame(dayjs(cursor), 'month');
const day = dayjsObj.get('date');
const label = dayjsObj.format(DATE_FORMAT);
const hasDot = withDotDates.has(date);

return (
<div
className={dayCell}
data-is-today={isToday}
data-is-selected={isSelected}
data-is-current-month={isCurrentMonth}
aria-label={label}
onClick={() => onSelect(date)}
>
{day}
{hasDot && <div className={dot} />}
</div>
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from 'react';

import { JournalDatePickerContext } from './context';
import { ResizeViewport } from './viewport';

export interface JournalDatePickerProps {
date: string;
onChange: (date: string) => void;
withDotDates: Set<string | null | undefined>;
}
export const JournalDatePicker = ({
date: selected,
onChange,
withDotDates,
}: JournalDatePickerProps) => {
const [cursor, setCursor] = useState(selected);

// should update cursor when selected modified outside
useEffect(() => {
setCursor(selected);
}, [selected]);

const onSelect = useCallback(
(date: string) => {
setCursor(date);
onChange(date);
},
[onChange]
);

return (
<JournalDatePickerContext.Provider
value={{
selected,
onSelect,
cursor,
setCursor,
width: window.innerWidth,
withDotDates,
}}
>
<ResizeViewport></ResizeViewport>
</JournalDatePickerContext.Provider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { style } from '@vanilla-extract/css';

export const monthViewClip = style({
width: '100%',
height: '100%',
overflow: 'hidden',
});

export const monthsSwipe = style({
width: '300%',
height: '100%',
marginLeft: '-100%',
display: 'flex',
justifyContent: 'center',
});

export const monthView = style({
width: 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: 4,
padding: '0 16px',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import anime from 'animejs';
import clsx from 'clsx';
import dayjs from 'dayjs';
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';

import {
CELL_HEIGHT,
DATE_FORMAT,
HORIZONTAL_SWIPE_THRESHOLD,
MONTH_VIEW_HEIGHT,
ROWS_GAP,
WEEK_VIEW_HEIGHT,
} from './constants';
import { JournalDatePickerContext } from './context';
import * as styles from './month.css';
import { SwipeHelper } from './swipe-helper';
import { getFirstDayOfMonth } from './utils';
import { WeekRow } from './week';
export interface MonthViewProps {
viewportHeight: number;
}

function getWeeks(date: string) {
const today = dayjs(date);
const firstDayOfMonth = today.startOf('month');
const firstWeekday = firstDayOfMonth.startOf('week');

const weeks = [];
for (let i = 0; i < 6; i++) {
const week = firstWeekday.add(i * 7, 'day');
weeks.push(week.format(DATE_FORMAT));
}

return weeks;
}

export const MonthView = ({ viewportHeight }: MonthViewProps) => {
const rootRef = useRef<HTMLDivElement>(null);
const swipeRef = useRef<HTMLDivElement>(null);
const { width, selected, onSelect, setCursor } = useContext(
JournalDatePickerContext
);
const [swiping, setSwiping] = useState(false);
const [animating, setAnimating] = useState(false);
const [swipingDeltaX, setSwipingDeltaX] = useState(0);

const weeks = useMemo(
() => getWeeks(selected ?? dayjs().format(DATE_FORMAT)),
[selected]
);

const firstWeekDayOfSelected = dayjs(selected)
.startOf('week')
.format(DATE_FORMAT);
const activeRowIndex = weeks.indexOf(firstWeekDayOfSelected);

// pointA: (WEEK_VIEW_HEIGHT, maxY)
// pointB: (MONTH_VIEW_HEIGHT, 0)
const maxY = -(activeRowIndex * (CELL_HEIGHT + ROWS_GAP));
const k = maxY / (WEEK_VIEW_HEIGHT - MONTH_VIEW_HEIGHT);
const b = -k * MONTH_VIEW_HEIGHT;
const translateY = k * viewportHeight + b;

const translateX = Math.max(-width, Math.min(width, swipingDeltaX));

const animateTo = useCallback(
(dir: 0 | 1 | -1) => {
setAnimating(true);

anime({
targets: swipeRef.current,
translateX: -dir * width,
duration: 300,
easing: 'easeInOutSine',
complete: () => {
setSwipingDeltaX(0);
setAnimating(false);
// should recover swipe before change month
if (dir !== 0) {
setTimeout(() => onSelect(getFirstDayOfMonth(selected, dir)));
}
},
});
},
[onSelect, selected, width]
);

useEffect(() => {
if (!rootRef.current) return;
const swipeHelper = new SwipeHelper();
return swipeHelper.init(rootRef.current, {
preventScroll: true,
onSwipe: ({ deltaX }) => {
setSwiping(true);
setSwipingDeltaX(deltaX);
if (Math.abs(deltaX) > HORIZONTAL_SWIPE_THRESHOLD) {
setCursor(getFirstDayOfMonth(selected, deltaX > 0 ? -1 : 1));
} else {
setCursor(selected);
}
},
onSwipeEnd: ({ deltaX }) => {
setSwiping(false);
if (Math.abs(deltaX) > HORIZONTAL_SWIPE_THRESHOLD) {
animateTo(deltaX > 0 ? -1 : 1);
} else {
animateTo(0);
}
},
});
}, [animateTo, selected, setCursor]);

return (
<div className={styles.monthViewClip} ref={rootRef}>
<div
ref={swipeRef}
className={styles.monthsSwipe}
style={{ transform: `translateX(${translateX}px)` }}
>
<MonthGrid
hidden={!swiping && !animating}
date={getFirstDayOfMonth(selected, -1)}
/>
{/* Active month */}
<MonthGrid
style={{ transform: `translateY(${translateY}px)` }}
date={selected ?? ''}
/>
<MonthGrid
hidden={!swiping && !animating}
date={getFirstDayOfMonth(selected, 1)}
/>
</div>
</div>
);
};

interface MonthGridProps extends React.HTMLAttributes<HTMLDivElement> {
date: string;
hidden?: boolean;
}
const MonthGrid = ({ date, className, hidden, ...props }: MonthGridProps) => {
const weeks = useMemo(
() => getWeeks(date ?? dayjs().format(DATE_FORMAT)),
[date]
);

return (
<div className={clsx(styles.monthView, className)} {...props}>
{hidden ? null : weeks.map(week => <WeekRow key={week} start={week} />)}
</div>
);
};
Loading

0 comments on commit 7903bed

Please sign in to comment.