refactor(component): move date-picker to ui, add story, support responsive (#5468)

- move to `component/ui`
- add `AFFiNEDatePicker` & `BlocksuiteDatePicker` story
- inline mode support
- responsive support
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="https://github.com/toeverything/AFFiNE/assets/39363750/320bef49-380f-40a2-b3b2-4b74dd2d8da4">
    <img  alt="" src="https://github.com/toeverything/AFFiNE/assets/39363750/fc9e7808-02fe-49a1-aa78-aea254fb1f9d">
  </picture>
This commit is contained in:
Cats Juice 2024-01-17 09:16:46 +00:00
parent 8f80bdb7af
commit 2db3c933fa
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
14 changed files with 454 additions and 278 deletions

View File

@ -1,3 +1,4 @@
import '../src/theme/global.css';
import './preview.css';
import { ThemeProvider, useTheme } from 'next-themes';
import type { ComponentType } from 'react';

View File

@ -31,6 +31,7 @@
"@emotion/react": "^11.11.1",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@lit/react": "^1.0.2",
"@popperjs/core": "^2.11.8",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",

View File

@ -1,191 +0,0 @@
import {
ArrowDownSmallIcon,
ArrowLeftSmallIcon,
ArrowRightSmallIcon,
} from '@blocksuite/icons';
import dayjs from 'dayjs';
import { useCallback, useState } from 'react';
import DatePicker from 'react-datepicker';
import * as styles from './index.css';
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
type DatePickerProps = {
value?: string;
onChange: (value: string) => void;
};
export const AFFiNEDatePicker = (props: DatePickerProps) => {
const { value, onChange } = props;
const [openMonthPicker, setOpenMonthPicker] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | null>(
value ? dayjs(value).toDate() : null
);
const handleOpenMonthPicker = useCallback(() => {
setOpenMonthPicker(true);
}, []);
const handleCloseMonthPicker = useCallback(() => {
setOpenMonthPicker(false);
}, []);
const handleSelectDate = (date: Date | null) => {
if (date) {
setSelectedDate(date);
onChange(dayjs(date).format('YYYY-MM-DD'));
setOpenMonthPicker(false);
}
};
const renderCustomHeader = ({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}: {
date: Date;
decreaseMonth: () => void;
increaseMonth: () => void;
prevMonthButtonDisabled: boolean;
nextMonthButtonDisabled: boolean;
}) => {
const selectedYear = dayjs(date).year();
const selectedMonth = dayjs(date).month();
return (
<div className={styles.headerStyle}>
<div
data-testid="date-picker-current-month"
className={styles.mouthStyle}
>
{months[selectedMonth]}
</div>
<div
data-testid="date-picker-current-year"
className={styles.yearStyle}
>
{selectedYear}
</div>
<div
data-testid="month-picker-button"
className={styles.arrowDownStyle}
onClick={handleOpenMonthPicker}
>
<ArrowDownSmallIcon />
</div>
<button
data-testid="date-picker-prev-button"
className={styles.arrowLeftStyle}
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
>
<ArrowLeftSmallIcon />
</button>
<button
data-testid="date-picker-next-button"
className={styles.arrowRightStyle}
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
>
<ArrowRightSmallIcon />
</button>
</div>
);
};
const renderCustomMonthHeader = ({
date,
decreaseYear,
increaseYear,
prevYearButtonDisabled,
nextYearButtonDisabled,
}: {
date: Date;
decreaseYear: () => void;
increaseYear: () => void;
prevYearButtonDisabled: boolean;
nextYearButtonDisabled: boolean;
}) => {
const selectedYear = dayjs(date).year();
return (
<div className={styles.monthHeaderStyle}>
<div
data-testid="month-picker-current-year"
className={styles.monthTitleStyle}
>
{selectedYear}
</div>
<button
data-testid="month-picker-prev-button"
className={styles.arrowLeftStyle}
onClick={decreaseYear}
disabled={prevYearButtonDisabled}
>
<ArrowLeftSmallIcon />
</button>
<button
data-testid="month-picker-next-button"
className={styles.arrowRightStyle}
onClick={increaseYear}
disabled={nextYearButtonDisabled}
>
<ArrowRightSmallIcon />
</button>
</div>
);
};
return (
<DatePicker
onClickOutside={handleCloseMonthPicker}
className={styles.inputStyle}
calendarClassName={styles.calendarStyle}
weekDayClassName={() => styles.weekStyle}
dayClassName={() => styles.dayStyle}
popperClassName={styles.popperStyle}
monthClassName={() => styles.mouthsStyle}
selected={selectedDate}
onChange={handleSelectDate}
showPopperArrow={false}
dateFormat="MMM dd"
showMonthYearPicker={openMonthPicker}
shouldCloseOnSelect={!openMonthPicker}
renderCustomHeader={({
date,
decreaseYear,
increaseYear,
decreaseMonth,
increaseMonth,
prevYearButtonDisabled,
nextYearButtonDisabled,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) =>
openMonthPicker
? renderCustomMonthHeader({
date,
decreaseYear,
increaseYear,
prevYearButtonDisabled,
nextYearButtonDisabled,
})
: renderCustomHeader({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
})
}
/>
);
};
export default AFFiNEDatePicker;

View File

@ -3,6 +3,7 @@ export * from './styles';
export * from './ui/avatar';
export * from './ui/button';
export * from './ui/checkbox';
export * from './ui/date-picker';
export * from './ui/divider';
export * from './ui/empty';
export * from './ui/input';

View File

@ -1,4 +1,3 @@
@import 'react-datepicker/dist/react-datepicker.css';
@import './fonts.css';
* {

View File

@ -0,0 +1,15 @@
import type { Meta, StoryFn } from '@storybook/react';
import { BlocksuiteDatePicker } from './blocksuite-date-picker';
export default {
title: 'UI/Date Picker/Blocksuite Date Picker',
} satisfies Meta<typeof BlocksuiteDatePicker>;
export const Basic: StoryFn<typeof BlocksuiteDatePicker> = () => {
return (
<div style={{ width: 300 }}>
<BlocksuiteDatePicker />
</div>
);
};

View File

@ -0,0 +1,11 @@
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { DatePicker } from '@blocksuite/blocks/src/_common/components/date-picker/index';
import { createComponent } from '@lit/react';
import React from 'react';
export const BlocksuiteDatePicker = createComponent({
tagName: 'date-picker',
elementClass: DatePicker,
react: React,
events: {},
});

View File

@ -0,0 +1,37 @@
import type { Meta, StoryFn } from '@storybook/react';
import dayjs from 'dayjs';
import { useState } from 'react';
import { AFFiNEDatePicker } from '.';
export default {
title: 'UI/Date Picker/Date Picker',
} satisfies Meta<typeof AFFiNEDatePicker>;
const _format = 'YYYY-MM-DD';
const Template: StoryFn<typeof AFFiNEDatePicker> = args => {
const [date, setDate] = useState(dayjs().format(_format));
return (
<div style={{ minHeight: 400, maxWidth: 600, margin: '0 auto' }}>
<div style={{ marginBottom: 20 }}>Selected Date: {date}</div>
<AFFiNEDatePicker
value={date}
{...args}
onChange={e => {
setDate(dayjs(e, _format).format(_format));
}}
/>
</div>
);
};
export const Basic: StoryFn<typeof AFFiNEDatePicker> = Template.bind(undefined);
Basic.args = {};
export const Inline: StoryFn<typeof AFFiNEDatePicker> =
Template.bind(undefined);
Inline.args = {
inline: true,
};

View File

@ -0,0 +1,257 @@
import {
ArrowDownSmallIcon,
ArrowLeftSmallIcon,
ArrowRightSmallIcon,
} from '@blocksuite/icons';
import clsx from 'clsx';
import dayjs from 'dayjs';
import { type HTMLAttributes, useCallback, useState } from 'react';
import DatePicker, { type ReactDatePickerProps } from 'react-datepicker';
import * as styles from './index.css';
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
export interface AFFiNEDatePickerProps
extends Omit<ReactDatePickerProps, 'onChange'> {
value?: string;
onChange: (value: string) => void;
}
interface HeaderLayoutProps extends HTMLAttributes<HTMLDivElement> {
length: number;
left: React.ReactNode;
right: React.ReactNode;
}
/**
* The `DatePicker` should work with different width
* This is a hack to make header's item align with calendar cell's label, **instead of the cell**
* @param length: number of items that calendar body row has
*/
const HeaderLayout = ({
length,
left,
right,
className,
...attrs
}: HeaderLayoutProps) => {
return (
<div className={clsx(styles.row, className)} {...attrs}>
{Array.from({ length })
.fill(0)
.map((_, index) => {
const isLeft = index === 0;
const isRight = index === length - 1;
return (
<div
key={index}
data-is-left={isLeft}
data-is-right={isRight}
className={styles.headerLayoutCell}
>
<div className={styles.headerLayoutCellOrigin}>
{isLeft ? left : isRight ? right : null}
</div>
</div>
);
})}
</div>
);
};
export const AFFiNEDatePicker = ({
value,
onChange,
...props
}: AFFiNEDatePickerProps) => {
const [openMonthPicker, setOpenMonthPicker] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | null>(
value ? dayjs(value).toDate() : null
);
const handleOpenMonthPicker = useCallback(() => {
setOpenMonthPicker(true);
}, []);
const handleCloseMonthPicker = useCallback(() => {
setOpenMonthPicker(false);
}, []);
const handleSelectDate = (date: Date | null) => {
if (date) {
setSelectedDate(date);
onChange(dayjs(date).format('YYYY-MM-DD'));
setOpenMonthPicker(false);
}
};
const renderCustomHeader = ({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}: {
date: Date;
decreaseMonth: () => void;
increaseMonth: () => void;
prevMonthButtonDisabled: boolean;
nextMonthButtonDisabled: boolean;
}) => {
const selectedYear = dayjs(date).year();
const selectedMonth = dayjs(date).month();
return (
<HeaderLayout
length={7}
className={styles.headerStyle}
left={
<div className={styles.headerLabel}>
<div
data-testid="date-picker-current-month"
className={styles.mouthStyle}
>
{months[selectedMonth]}
</div>
<div
data-testid="date-picker-current-year"
className={styles.yearStyle}
>
{selectedYear}
</div>
<div
data-testid="month-picker-button"
className={styles.arrowDownStyle}
onClick={handleOpenMonthPicker}
>
<ArrowDownSmallIcon />
</div>
</div>
}
right={
<div className={styles.headerActionWrapper}>
<button
data-testid="date-picker-prev-button"
className={styles.headerAction}
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
>
<ArrowLeftSmallIcon />
</button>
<button
data-testid="date-picker-next-button"
className={styles.headerAction}
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
>
<ArrowRightSmallIcon />
</button>
</div>
}
/>
);
};
const renderCustomMonthHeader = ({
date,
decreaseYear,
increaseYear,
prevYearButtonDisabled,
nextYearButtonDisabled,
}: {
date: Date;
decreaseYear: () => void;
increaseYear: () => void;
prevYearButtonDisabled: boolean;
nextYearButtonDisabled: boolean;
}) => {
const selectedYear = dayjs(date).year();
return (
<HeaderLayout
length={3}
className={styles.monthHeaderStyle}
left={
<div
data-testid="month-picker-current-year"
className={styles.monthTitleStyle}
>
{selectedYear}
</div>
}
right={
<div className={styles.headerActionWrapper}>
<button
data-testid="month-picker-prev-button"
className={styles.headerAction}
onClick={decreaseYear}
disabled={prevYearButtonDisabled}
>
<ArrowLeftSmallIcon />
</button>
<button
data-testid="month-picker-next-button"
className={styles.headerAction}
onClick={increaseYear}
disabled={nextYearButtonDisabled}
>
<ArrowRightSmallIcon />
</button>
</div>
}
/>
);
};
return (
<DatePicker
onClickOutside={handleCloseMonthPicker}
className={styles.inputStyle}
calendarClassName={styles.calendarStyle}
weekDayClassName={() => styles.weekStyle}
dayClassName={() => styles.dayStyle}
popperClassName={styles.popperStyle}
monthClassName={() => styles.mouthsStyle}
selected={selectedDate}
onChange={handleSelectDate}
showPopperArrow={false}
dateFormat="MMM dd"
showMonthYearPicker={openMonthPicker}
shouldCloseOnSelect={!openMonthPicker}
renderCustomHeader={({
date,
decreaseYear,
increaseYear,
decreaseMonth,
increaseMonth,
prevYearButtonDisabled,
nextYearButtonDisabled,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) =>
openMonthPicker
? renderCustomMonthHeader({
date,
decreaseYear,
increaseYear,
prevYearButtonDisabled,
nextYearButtonDisabled,
})
: renderCustomHeader({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
})
}
{...props}
/>
);
};
export default AFFiNEDatePicker;

View File

@ -1,5 +1,38 @@
import { globalStyle, style } from '@vanilla-extract/css';
/**
* we do not import css from 'react-date-picker' anymore
**/
globalStyle('.react-datepicker__aria-live', {
display: 'none',
});
export const basicCell = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '28px',
maxWidth: '50px',
flex: '1',
userSelect: 'none',
});
export const headerLayoutCell = style([basicCell]);
export const headerLayoutCellOrigin = style({
width: 0,
height: 'fit-content',
display: 'flex',
selectors: {
'[data-is-left="true"] &': {
justifyContent: 'flex-start',
marginLeft: '-12px',
},
'[data-is-right="true"] &': {
justifyContent: 'flex-end',
marginRight: '-24px',
},
},
});
export const inputStyle = style({
fontSize: 'var(--affine-font-xs)',
width: '50px',
@ -16,52 +49,40 @@ export const inputStyle = style({
});
export const popperStyle = style({
boxShadow: 'var(--affine-shadow-2)',
padding: '0 10px',
// TODO: for menu offset, need to be optimized
marginTop: '16px',
background: 'var(--affine-background-overlay-panel-color)',
borderRadius: '12px',
width: '300px',
zIndex: 'var(--affine-z-index-popover)',
});
globalStyle('.react-datepicker__header', {
background: 'var(--affine-background-overlay-panel-color)',
background: 'none',
border: 'none',
marginBottom: '6px',
marginBottom: '8px',
});
globalStyle('.react-datepicker__header, .react-datepicker__month', {
display: 'flex',
flexDirection: 'column',
gap: '8px',
});
export const headerStyle = style({
background: 'var(--affine-background-overlay-panel-color)',
border: 'none',
display: 'flex',
width: '100%',
alignItems: 'center',
marginBottom: '12px',
padding: '0 14px',
position: 'relative',
justifyContent: 'space-between',
width: '100%',
});
export const monthHeaderStyle = style({
background: 'var(--affine-background-overlay-panel-color)',
border: 'none',
display: 'flex',
width: '100%',
alignItems: 'center',
marginBottom: '18px',
padding: '0 14px',
position: 'relative',
'::after': {
content: '""',
position: 'absolute',
width: 'calc(100% - 24px)',
height: '1px',
background: 'var(--affine-border-color)',
bottom: '-18px',
left: '12px',
},
});
export const monthTitleStyle = style({
color: 'var(--affine-text-primary-color)',
fontWeight: '600',
fontSize: 'var(--affine-font-sm)',
marginLeft: '12px',
});
export const yearStyle = style({
marginLeft: '8px',
@ -73,77 +94,92 @@ export const mouthStyle = style({
color: 'var(--affine-text-primary-color)',
fontWeight: '600',
fontSize: 'var(--affine-font-sm)',
cursor: 'pointer',
textAlign: 'center',
});
export const arrowLeftStyle = style({
export const headerLabel = style({
display: 'flex',
alignItems: 'center',
});
export const headerActionWrapper = style({
display: 'flex',
alignItems: 'center',
gap: 24,
});
export const headerAction = style({
width: '16px',
height: '16px',
textAlign: 'right',
position: 'absolute',
right: '50px',
});
export const arrowRightStyle = style({
width: '16px',
height: '16px',
right: '14px',
position: 'absolute',
globalStyle('.react-datepicker__day-names, .react-datepicker__week', {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
});
export const weekStyle = style({
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
display: 'inline-block',
width: '28px',
height: '28px',
lineHeight: '28px',
padding: '0 4px',
margin: '0px 6px',
verticalAlign: 'middle',
export const row = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
});
// header day cell
export const weekStyle = style([
basicCell,
{
height: '28px',
fontSize: 'var(--affine-font-xs)',
fontWeight: 500,
color: 'var(--affine-text-secondary-color)',
},
]);
export const calendarStyle = style({
background: 'var(--affine-background-overlay-panel-color)',
background: 'none',
border: 'none',
width: '100%',
padding: '20px',
});
export const dayStyle = style({
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-primary-color)',
display: 'inline-block',
width: '28px',
height: '28px',
lineHeight: '28px',
padding: '0 4px',
margin: '6px 12px 6px 0px',
verticalAlign: 'middle',
fontWeight: '400',
borderRadius: '8px',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color)',
borderRadius: '8px',
transition: 'background-color 0.3s ease-in-out',
},
'&[aria-selected="true"]': {
color: 'var(--affine-black)',
background: 'var(--affine-hover-color)',
},
'&[aria-selected="true"]:hover': {
background: 'var(--affine-hover-color)',
},
'&[tabindex="0"][aria-selected="false"]': {
background: 'var(--affine-background-overlay-panel-color)',
},
'&.react-datepicker__day--today[aria-selected="false"]': {
background: 'var(--affine-primary-color)',
color: 'var(--affine-palette-line-white)',
},
'&.react-datepicker__day--today[aria-selected="false"]:hover': {
color: 'var(--affine-black)',
background: 'var(--affine-hover-color)',
},
'&.react-datepicker__day--outside-month[aria-selected="false"]': {
color: 'var(--affine-text-disable-color)',
export const dayStyle = style([
basicCell,
{
height: '28px',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-primary-color)',
cursor: 'pointer',
fontWeight: '400',
borderRadius: '8px',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color)',
borderRadius: '8px',
transition: 'background-color 0.3s ease-in-out',
},
'&[aria-selected="true"]': {
color: 'var(--affine-black)',
background: 'var(--affine-hover-color)',
},
'&[aria-selected="true"]:hover': {
background: 'var(--affine-hover-color)',
},
'&[tabindex="0"][aria-selected="false"]': {
background: 'var(--affine-background-overlay-panel-color)',
},
'&.react-datepicker__day--today[aria-selected="false"]': {
background: 'var(--affine-primary-color)',
color: 'var(--affine-palette-line-white)',
},
'&.react-datepicker__day--today[aria-selected="false"]:hover': {
color: 'var(--affine-black)',
background: 'var(--affine-hover-color)',
},
'&.react-datepicker__day--outside-month[aria-selected="false"]': {
color: 'var(--affine-text-disable-color)',
},
},
},
});
]);
export const arrowDownStyle = style({
width: '16px',
height: '16px',

View File

@ -1,5 +1,4 @@
import { Input, Menu, MenuItem } from '@affine/component';
import { AFFiNEDatePicker } from '@affine/component/date-picker';
import { AFFiNEDatePicker, Input, Menu, MenuItem } from '@affine/component';
import type { LiteralValue, Tag } from '@affine/env/filter';
import dayjs from 'dayjs';
import { type ReactNode } from 'react';

View File

@ -1,4 +1,4 @@
import { AFFiNEDatePicker } from '@affine/component/date-picker';
import { AFFiNEDatePicker } from '@affine/component';
import type { Meta, StoryFn } from '@storybook/react';
import { useState } from 'react';
@ -8,7 +8,7 @@ export default {
parameters: {
chromatic: { disableSnapshot: true },
},
} satisfies Meta;
} satisfies Meta<typeof AFFiNEDatePicker>;
export const Default: StoryFn = () => {
const [value, setValue] = useState<string>(new Date().toString());

View File

@ -219,6 +219,7 @@ __metadata:
"@emotion/react": "npm:^11.11.1"
"@emotion/server": "npm:^11.11.0"
"@emotion/styled": "npm:^11.11.0"
"@lit/react": "npm:^1.0.2"
"@popperjs/core": "npm:^2.11.8"
"@radix-ui/react-avatar": "npm:^1.0.4"
"@radix-ui/react-collapsible": "npm:^1.0.3"
@ -6488,6 +6489,15 @@ __metadata:
languageName: node
linkType: hard
"@lit/react@npm:^1.0.2":
version: 1.0.2
resolution: "@lit/react@npm:1.0.2"
peerDependencies:
"@types/react": 17 || 18
checksum: 78bc607f9022ceefa5c714a701b71e444df8d6c88c75b0fe728b7157d3ec00a7fa459bc3c4ce5a4b5315079159c9759be4738e5ade656f8d74e32f729f75efc4
languageName: node
linkType: hard
"@lit/reactive-element@npm:^2.0.0":
version: 2.0.2
resolution: "@lit/reactive-element@npm:2.0.2"