feat(component): new week-date-picker component (#5477)

<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://github.com/toeverything/AFFiNE/assets/39363750/49d7a1ee-2832-4b61-a427-e627dae92952">
  <img height="100" alt="" src="https://github.com/toeverything/AFFiNE/assets/39363750/819d6ee9-38e0-4537-ad0f-ec9faf96f505">
</picture>
This commit is contained in:
Cats Juice
2024-01-18 02:14:27 +00:00
parent ee8ec47a4f
commit 2b92b27f8f
7 changed files with 555 additions and 1 deletions

View File

@@ -1 +1,2 @@
export * from './date-picker';
export * from './week-date-picker';

View File

@@ -0,0 +1,101 @@
import { style } from '@vanilla-extract/css';
export const weekDatePicker = style({
display: 'flex',
alignItems: 'center',
gap: 4,
height: '100%',
maxHeight: '39px',
});
export const weekDatePickerContent = style({
width: 0,
flex: 1,
display: 'flex',
alignItems: 'stretch',
justifyContent: 'space-between',
gap: 4,
userSelect: 'none',
});
export const dayCell = style({
position: 'relative',
width: 0,
flexGrow: 1,
minWidth: 30,
maxWidth: 130,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '2px 4px 1px 4px',
borderRadius: 4,
fontFamily: 'var(--affine-font-family)',
fontWeight: 500,
fontSize: 12,
selectors: {
'&:hover': {
backgroundColor: 'var(--affine-hover-color)',
},
'&[data-today="true"]:not([data-active="true"])': {
vars: {
'--cell-color': 'var(--affine-brand-color)',
},
},
'&[data-active="true"]': {
vars: {
'--cell-color': 'var(--affine-pure-white)',
},
background: 'var(--affine-brand-color)',
},
// interactive
'&::before, &::after': {
content: '',
position: 'absolute',
inset: 0,
zIndex: 1,
pointerEvents: 'none',
borderRadius: 'inherit',
opacity: 0,
},
'&::before': {
boxShadow: '0 0 0 2px var(--affine-brand-color)',
},
'&::after': {
border: '1px solid var(--affine-brand-color)',
},
'&:focus-visible::before': {
opacity: 0.5,
},
'&:focus-visible::after': {
opacity: 1,
},
},
});
export const dayCellWeek = style({
width: '100%',
height: 16,
lineHeight: '16px',
textAlign: 'center',
textOverflow: 'clip',
whiteSpace: 'nowrap',
overflow: 'hidden',
color: 'var(--cell-color, var(--affine-text-secondary-color))',
});
export const dayCellDate = style({
width: '100%',
height: 20,
lineHeight: '20px',
textAlign: 'center',
color: 'var(--cell-color, var(--affine-text-primary-color))',
});

View File

@@ -0,0 +1,48 @@
import type { Meta, StoryFn } from '@storybook/react';
import dayjs from 'dayjs';
import { useState } from 'react';
import { ResizePanel } from '../resize-panel/resize-panel';
import { WeekDatePicker } from './week-date-picker';
export default {
title: 'UI/Date Picker/Week Date Picker',
} satisfies Meta<typeof WeekDatePicker>;
const _format = 'YYYY-MM-DD';
const Template: StoryFn<typeof WeekDatePicker> = args => {
const [date, setDate] = useState(dayjs().format(_format));
return (
<div style={{ paddingBottom: 100 }}>
<div style={{ marginBottom: 20 }}>Selected Date: {date}</div>
<ResizePanel
width={600}
height={56}
minHeight={56}
minWidth={100}
maxWidth={1400}
horizontal={true}
vertical={false}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'stretch',
}}
>
<WeekDatePicker
style={{ width: '100%' }}
value={date}
{...args}
onChange={e => {
setDate(dayjs(e, _format).format(_format));
}}
/>
</ResizePanel>
</div>
);
};
export const Basic: StoryFn<typeof WeekDatePicker> = Template.bind(undefined);
Basic.args = {};

View File

@@ -0,0 +1,249 @@
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import dayjs from 'dayjs';
import {
type ForwardedRef,
type HTMLAttributes,
memo,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { IconButton } from '../button';
import * as styles from './week-date-picker.css';
export interface WeekDatePickerHandle {
/** control cursor manually */
setCursor?: (cursor: dayjs.Dayjs) => void;
}
export interface WeekDatePickerProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
value?: string;
onChange?: (value: string) => void;
handleRef?: ForwardedRef<WeekDatePickerHandle>;
}
// TODO: i18n
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
const format = 'YYYY-MM-DD';
export const WeekDatePicker = memo(function WeekDatePicker({
value,
onChange,
className,
handleRef,
...attrs
}: WeekDatePickerProps) {
const weekRef = useRef<HTMLDivElement | null>(null);
const [cursor, setCursor] = useState(dayjs(value));
const [range, setRange] = useState([0, 7]);
const [dense, setDense] = useState(false);
const [allDays, setAllDays] = useState<dayjs.Dayjs[]>([]);
const [viewPortSize, setViewPortSize] = useState(7);
useImperativeHandle(handleRef, () => ({
setCursor,
}));
const displayDays = allDays.slice(...range);
const updateRange = useCallback(
(newRange: [number, number]) => {
if (range && newRange[0] === range[0] && newRange[1] === range[1]) return;
setRange(newRange);
},
[range]
);
const onNext = useCallback(() => {
if (viewPortSize === 7) {
setCursor(cursor.add(1, 'week'));
} else if (range[1] === 7) {
setCursor(cursor.add(1, 'week'));
updateRange([0, viewPortSize]);
} else {
const end = Math.min(range[1] + viewPortSize, 7);
const start = Math.min(range[0] + viewPortSize, end - viewPortSize);
updateRange([start, end]);
}
}, [cursor, range, updateRange, viewPortSize]);
const onPrev = useCallback(() => {
if (viewPortSize === 7) {
setCursor(cursor.add(-1, 'week'));
} else if (range[0] === 0) {
setCursor(cursor.add(-1, 'week'));
updateRange([7 - viewPortSize, 7]);
} else {
const start = Math.max(range[0] - viewPortSize, 0);
const end = Math.max(range[1] - viewPortSize, start + viewPortSize);
updateRange([start, end]);
}
}, [cursor, range, updateRange, viewPortSize]);
const onDayClick = useCallback(
(day: dayjs.Dayjs) => {
onChange?.(day.format(format));
},
[onChange]
);
// Observe weekRef to update viewPortSize
useEffect(() => {
const el = weekRef.current;
if (!el) return;
const resizeObserver = new ResizeObserver(entries => {
const rect = entries[0].contentRect;
const width = rect.width;
if (!width) return;
const minWidth = 30;
const gap = 4;
const viewPortCount = Math.floor(width / (minWidth + gap));
setViewPortSize(Math.max(1, Math.min(viewPortCount, 7)));
setDense(width < 300);
});
resizeObserver.observe(el);
return () => {
resizeObserver.disconnect();
};
}, []);
// update allDays when cursor changes
useEffect(() => {
const firstDay = dayjs(cursor).startOf('week');
if (allDays[0] && firstDay.isSame(allDays[0], 'day')) return;
setAllDays(
Array.from({ length: 7 }).map((_, index) =>
firstDay.add(index, 'day').startOf('day')
)
);
}, [allDays, cursor]);
// when viewPortSize changes, reset range
useEffect(() => {
if (viewPortSize >= 7) updateRange([0, 7]);
else {
const end = Math.min(7, range[0] + viewPortSize);
const start = Math.max(0, end - viewPortSize);
updateRange([start, end]);
}
}, [range, updateRange, viewPortSize]);
// when value changes, reset cursor
useEffect(() => {
value && setCursor(dayjs(value));
}, [value]);
// TODO: keyboard navigation
useEffect(() => {
if (!weekRef.current) return;
const el = weekRef.current;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
e.stopPropagation();
const focused = document.activeElement as HTMLElement;
if (!focused) return el.querySelector('button')?.focus();
const day = dayjs(focused.dataset.value);
if (
(day.day() === 0 && e.key === 'ArrowLeft') ||
(e.key === 'ArrowLeft' && !focused.previousElementSibling)
) {
onPrev();
requestAnimationFrame(() => {
el.querySelector('button')?.focus();
});
}
if (
(day.day() === 6 && e.key === 'ArrowRight') ||
(e.key === 'ArrowRight' && !focused.nextElementSibling)
) {
onNext();
requestAnimationFrame(() => {
(el.querySelector('button:last-child') as HTMLElement)?.focus();
});
}
if (e.key === 'ArrowLeft' && focused.previousElementSibling) {
(focused.previousElementSibling as HTMLElement).focus();
}
if (e.key === 'ArrowRight' && focused.nextElementSibling) {
(focused.nextElementSibling as HTMLElement).focus();
}
};
el.addEventListener('keydown', onKeyDown);
return () => {
el.removeEventListener('keydown', onKeyDown);
};
}, [onNext, onPrev]);
return (
<div className={clsx(styles.weekDatePicker, className)} {...attrs}>
<IconButton onClick={onPrev}>
<ArrowLeftSmallIcon />
</IconButton>
<div ref={weekRef} className={styles.weekDatePickerContent}>
{displayDays.map(day => (
<Cell
key={day.toISOString()}
dense={dense}
value={value}
day={day}
cursor={cursor}
onClick={onDayClick}
/>
))}
</div>
<IconButton onClick={onNext}>
<ArrowRightSmallIcon />
</IconButton>
</div>
);
});
interface CellProps {
dense: boolean;
day: dayjs.Dayjs;
cursor: dayjs.Dayjs;
value?: string;
onClick: (day: dayjs.Dayjs) => void;
}
const Cell = ({ day, dense, value, cursor, onClick }: CellProps) => {
const isActive = day.format(format) === value;
const isCurrentMonth = day.month() === cursor.month();
const isToday = day.isSame(dayjs(), 'day');
const dayIndex = day.day();
const label = weekDays[dayIndex];
return (
<button
tabIndex={0}
aria-label={day.format(format)}
data-active={isActive}
data-curr-month={isCurrentMonth}
data-today={isToday}
data-value={day.format(format)}
key={day.toISOString()}
className={styles.dayCell}
onClick={() => onClick(day)}
>
<div className={styles.dayCellWeek}>
{dense ? label.slice(0, 1) : label}
</div>
<div className={styles.dayCellDate}>{day.format('D')}</div>
</button>
);
};

View File

@@ -0,0 +1,122 @@
import clsx from 'clsx';
import {
type HTMLAttributes,
type PropsWithChildren,
useEffect,
useRef,
} from 'react';
import * as styles from './styles.css';
export interface ResizePanelProps
extends PropsWithChildren,
HTMLAttributes<HTMLDivElement> {
horizontal?: boolean;
vertical?: boolean;
width?: number;
height?: number;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
}
/**
* This component is used for debugging responsive layout in storybook
* @internal
*/
export const ResizePanel = ({
width,
height,
children,
minHeight,
minWidth,
maxHeight,
maxWidth,
className,
horizontal = true,
vertical = true,
...attrs
}: ResizePanelProps) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const cornerHandleRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!containerRef.current || !cornerHandleRef.current) return;
const containerEl = containerRef.current;
const cornerHandleEl = cornerHandleRef.current;
let startPos: [number, number] = [0, 0];
let startSize: [number, number] = [0, 0];
const onDragStart = (e: MouseEvent) => {
startPos = [e.clientX, e.clientY];
startSize = [containerEl.offsetWidth, containerEl.offsetHeight];
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', onDragEnd);
};
const onDrag = (e: MouseEvent) => {
const pos = [e.clientX, e.clientY];
const delta = [pos[0] - startPos[0], pos[1] - startPos[1]];
const newSize = [startSize[0] + delta[0], startSize[1] + delta[1]];
updateSize(newSize);
};
const onDragEnd = () => {
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', onDragEnd);
};
const updateSize = (size: number[]) => {
if (!containerEl) return;
if (horizontal) {
const width = Math.max(
Math.min(size[0], maxWidth ?? Infinity),
minWidth ?? 0
);
containerEl.style.width = `${width}px`;
}
if (vertical) {
const height = Math.max(
Math.min(size[1], maxHeight ?? Infinity),
minHeight ?? 0
);
containerEl.style.height = `${height}px`;
}
};
updateSize([width ?? 400, height ?? 200]);
cornerHandleEl.addEventListener('mousedown', onDragStart);
return () => {
cornerHandleEl.removeEventListener('mousedown', onDragStart);
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', onDragEnd);
};
}, [
height,
horizontal,
maxHeight,
maxWidth,
minHeight,
minWidth,
vertical,
width,
]);
return (
<div
ref={containerRef}
className={clsx(styles.container, className)}
{...attrs}
>
{children}
<div ref={cornerHandleRef} className={styles.cornerHandle}></div>
</div>
);
};

View File

@@ -0,0 +1,26 @@
import { style } from '@vanilla-extract/css';
const HANDLE_SIZE = 24;
export const container = style({
position: 'relative',
border: '1px solid rgba(100, 100, 100, 0.2)',
padding: 8,
borderRadius: 4,
borderBottomRightRadius: HANDLE_SIZE / 2,
});
export const cornerHandle = style({
position: 'absolute',
top: `calc(100% - ${HANDLE_SIZE / 1.5}px)`,
left: `calc(100% - ${HANDLE_SIZE / 1.5}px)`,
width: HANDLE_SIZE,
height: HANDLE_SIZE,
borderRadius: '50%',
border: '2px solid transparent',
borderRightColor: 'rgba(100, 100, 100, 0.3)',
transform: 'rotate(45deg)',
cursor: 'nwse-resize',
});