mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 04:48:53 +00:00
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:
@@ -1 +1,2 @@
|
||||
export * from './date-picker';
|
||||
export * from './week-date-picker';
|
||||
|
||||
@@ -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))',
|
||||
});
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
122
packages/frontend/component/src/ui/resize-panel/resize-panel.tsx
Normal file
122
packages/frontend/component/src/ui/resize-panel/resize-panel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
Reference in New Issue
Block a user