diff --git a/packages/frontend/component/src/ui/date-picker/index.ts b/packages/frontend/component/src/ui/date-picker/index.ts index e5ce37c674..691bfd7ada 100644 --- a/packages/frontend/component/src/ui/date-picker/index.ts +++ b/packages/frontend/component/src/ui/date-picker/index.ts @@ -1 +1,2 @@ export * from './date-picker'; +export * from './week-date-picker'; diff --git a/packages/frontend/component/src/ui/date-picker/week-date-picker.css.tsx b/packages/frontend/component/src/ui/date-picker/week-date-picker.css.tsx new file mode 100644 index 0000000000..6b7442d74f --- /dev/null +++ b/packages/frontend/component/src/ui/date-picker/week-date-picker.css.tsx @@ -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))', +}); diff --git a/packages/frontend/component/src/ui/date-picker/week-date-picker.stories.tsx b/packages/frontend/component/src/ui/date-picker/week-date-picker.stories.tsx new file mode 100644 index 0000000000..4beb72111a --- /dev/null +++ b/packages/frontend/component/src/ui/date-picker/week-date-picker.stories.tsx @@ -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; + +const _format = 'YYYY-MM-DD'; + +const Template: StoryFn = args => { + const [date, setDate] = useState(dayjs().format(_format)); + return ( +
+
Selected Date: {date}
+ + + { + setDate(dayjs(e, _format).format(_format)); + }} + /> + +
+ ); +}; + +export const Basic: StoryFn = Template.bind(undefined); +Basic.args = {}; diff --git a/packages/frontend/component/src/ui/date-picker/week-date-picker.tsx b/packages/frontend/component/src/ui/date-picker/week-date-picker.tsx new file mode 100644 index 0000000000..68e1f53cc9 --- /dev/null +++ b/packages/frontend/component/src/ui/date-picker/week-date-picker.tsx @@ -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, 'onChange'> { + value?: string; + onChange?: (value: string) => void; + handleRef?: ForwardedRef; +} + +// 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(null); + + const [cursor, setCursor] = useState(dayjs(value)); + const [range, setRange] = useState([0, 7]); + const [dense, setDense] = useState(false); + const [allDays, setAllDays] = useState([]); + 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 ( +
+ + + + +
+ {displayDays.map(day => ( + + ))} +
+ + + + +
+ ); +}); + +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 ( + + ); +}; diff --git a/packages/frontend/component/src/ui/resize-panel/resize-panel.tsx b/packages/frontend/component/src/ui/resize-panel/resize-panel.tsx new file mode 100644 index 0000000000..778af0c008 --- /dev/null +++ b/packages/frontend/component/src/ui/resize-panel/resize-panel.tsx @@ -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 { + 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(null); + const cornerHandleRef = useRef(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 ( +
+ {children} +
+
+ ); +}; diff --git a/packages/frontend/component/src/ui/resize-panel/styles.css.ts b/packages/frontend/component/src/ui/resize-panel/styles.css.ts new file mode 100644 index 0000000000..bd9c81ca0b --- /dev/null +++ b/packages/frontend/component/src/ui/resize-panel/styles.css.ts @@ -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', +}); diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 34fdb49079..98f484ba7d 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1028,5 +1028,12 @@ "com.affine.page-operation.add-linked-page": "Add linked page", "com.affine.onboarding.workspace-guide.title": "Start AFFiNE by creating your own Workspace here!", "com.affine.onboarding.workspace-guide.content": "A Workspace is your virtual space to capture, create and plan as just one person or together as a team.", - "com.affine.onboarding.workspace-guide.got-it": "Got it!" + "com.affine.onboarding.workspace-guide.got-it": "Got it!", + "com.affine.calendar.weekdays.sun": "Sun", + "com.affine.calendar.weekdays.mon": "Mon", + "com.affine.calendar.weekdays.tue": "Tue", + "com.affine.calendar.weekdays.wed": "Wed", + "com.affine.calendar.weekdays.thu": "Thu", + "com.affine.calendar.weekdays.fri": "Fri", + "com.affine.calendar.weekdays.sat": "Sat" }