mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 20:46:38 +08:00
refactor(infra): directory structure (#4615)
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import type { BreadcrumbsProps } from '@mui/material/Breadcrumbs';
|
||||
import MuiBreadcrumbs from '@mui/material/Breadcrumbs';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
import { styled } from '../../styles';
|
||||
|
||||
const StyledMuiBreadcrumbs = styled(MuiBreadcrumbs)(() => {
|
||||
return {
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
};
|
||||
});
|
||||
|
||||
export const Breadcrumbs: ComponentType<BreadcrumbsProps> =
|
||||
StyledMuiBreadcrumbs;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ArrowDownSmallIcon } from '@blocksuite/icons';
|
||||
import {
|
||||
type ButtonHTMLAttributes,
|
||||
forwardRef,
|
||||
type MouseEventHandler,
|
||||
} from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
type DropdownButtonProps = {
|
||||
onClickDropDown?: MouseEventHandler<HTMLElement>;
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
export const DropdownButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
DropdownButtonProps
|
||||
>(({ onClickDropDown, children, ...props }, ref) => {
|
||||
const handleClickDropDown: MouseEventHandler<HTMLElement> = e => {
|
||||
e.stopPropagation();
|
||||
onClickDropDown?.(e);
|
||||
};
|
||||
return (
|
||||
<button ref={ref} className={styles.dropdownBtn} {...props}>
|
||||
<span>{children}</span>
|
||||
<span className={styles.divider} />
|
||||
<span className={styles.dropdownWrapper} onClick={handleClickDropDown}>
|
||||
<ArrowDownSmallIcon
|
||||
className={styles.dropdownIcon}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
DropdownButton.displayName = 'DropdownButton';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './dropdown';
|
||||
export * from './radio';
|
||||
@@ -0,0 +1,26 @@
|
||||
import type {
|
||||
CSSProperties,
|
||||
HTMLAttributes,
|
||||
PropsWithChildren,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
|
||||
export const SIZE_SMALL = 'small' as const;
|
||||
export const SIZE_MIDDLE = 'middle' as const;
|
||||
export const SIZE_DEFAULT = 'default' as const;
|
||||
|
||||
export type ButtonProps = PropsWithChildren &
|
||||
Omit<HTMLAttributes<HTMLButtonElement>, 'type'> & {
|
||||
size?: typeof SIZE_SMALL | typeof SIZE_MIDDLE | typeof SIZE_DEFAULT;
|
||||
disabled?: boolean;
|
||||
hoverBackground?: CSSProperties['background'];
|
||||
hoverColor?: CSSProperties['color'];
|
||||
hoverStyle?: CSSProperties;
|
||||
icon?: ReactElement;
|
||||
iconPosition?: 'start' | 'end';
|
||||
shape?: 'default' | 'round' | 'circle';
|
||||
type?: 'primary' | 'light' | 'warning' | 'danger' | 'default';
|
||||
bold?: boolean;
|
||||
loading?: boolean;
|
||||
noBorder?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { styled } from '../../styles';
|
||||
import type { ButtonProps } from './interface';
|
||||
import { getButtonColors } from './utils';
|
||||
export const LoadingContainer = styled('div')<Pick<ButtonProps, 'type'>>(({
|
||||
theme,
|
||||
type = 'default',
|
||||
}) => {
|
||||
const { color } = getButtonColors(theme, type, false);
|
||||
return `
|
||||
margin: 0px auto;
|
||||
width: 38px;
|
||||
text-align: center;
|
||||
.load {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: ${color};
|
||||
|
||||
border-radius: 100%;
|
||||
display: inline-block;
|
||||
-webkit-animation: bouncedelay 1.4s infinite ease-in-out;
|
||||
animation: bouncedelay 1.4s infinite ease-in-out;
|
||||
/* Prevent first frame from flickering when animation starts */
|
||||
-webkit-animation-fill-mode: both;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.load1 {
|
||||
-webkit-animation-delay: -0.32s;
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
.load2 {
|
||||
-webkit-animation-delay: -0.16s;
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@-webkit-keyframes bouncedelay {
|
||||
0%, 80%, 100% { -webkit-transform: scale(0) }
|
||||
40% { -webkit-transform: scale(1.0) }
|
||||
}
|
||||
|
||||
@keyframes bouncedelay {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0);
|
||||
-webkit-transform: scale(0);
|
||||
} 40% {
|
||||
transform: scale(1.0);
|
||||
-webkit-transform: scale(1.0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
});
|
||||
|
||||
export const Loading = ({ type }: Pick<ButtonProps, 'type'>) => {
|
||||
return (
|
||||
<LoadingContainer type={type} className="load-container">
|
||||
<div className="load load1"></div>
|
||||
<div className="load load2"></div>
|
||||
<div className="load"></div>
|
||||
</LoadingContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
RadioGroupItemProps,
|
||||
RadioGroupProps,
|
||||
} from '@radix-ui/react-radio-group';
|
||||
import * as RadioGroup from '@radix-ui/react-radio-group';
|
||||
import clsx from 'clsx';
|
||||
import { type CSSProperties, forwardRef } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const RadioButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
RadioGroupItemProps & { spanStyle?: string }
|
||||
>(({ children, className, spanStyle, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroup.Item
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={clsx(styles.radioButton, className)}
|
||||
>
|
||||
<span className={clsx(styles.radioUncheckedButton, spanStyle)}>
|
||||
{children}
|
||||
</span>
|
||||
<RadioGroup.Indicator
|
||||
className={clsx(styles.radioButtonContent, spanStyle)}
|
||||
>
|
||||
{children}
|
||||
</RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
);
|
||||
});
|
||||
RadioButton.displayName = 'RadioButton';
|
||||
|
||||
export const RadioButtonGroup = forwardRef<
|
||||
HTMLDivElement,
|
||||
RadioGroupProps & { width?: CSSProperties['width'] }
|
||||
>(({ className, style, width, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroup.Root
|
||||
ref={ref}
|
||||
className={clsx(styles.radioButtonGroup, className)}
|
||||
style={{ width, ...style }}
|
||||
{...props}
|
||||
></RadioGroup.Root>
|
||||
);
|
||||
});
|
||||
RadioButtonGroup.displayName = 'RadioButtonGroup';
|
||||
@@ -0,0 +1,363 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const button = style({
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
touchAction: 'manipulation',
|
||||
outline: '0',
|
||||
border: '1px solid',
|
||||
padding: '0 18px',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'var(--affine-font-base)',
|
||||
transition: 'all .3s',
|
||||
['WebkitAppRegion' as string]: 'no-drag',
|
||||
fontWeight: 600,
|
||||
|
||||
// changeable
|
||||
height: '28px',
|
||||
background: 'var(--affine-white)',
|
||||
borderColor: 'var(--affine-border-color)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
|
||||
selectors: {
|
||||
'&.text-bold': {
|
||||
fontWeight: 600,
|
||||
},
|
||||
'&:not(.without-hover):hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
'&.disabled': {
|
||||
opacity: '.4',
|
||||
cursor: 'default',
|
||||
color: 'var(--affine-disable-color)',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&.loading': {
|
||||
cursor: 'default',
|
||||
color: 'var(--affine-disable-color)',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&.disabled:not(.without-hover):hover, &.loading:not(.without-hover):hover':
|
||||
{
|
||||
background: 'inherit',
|
||||
},
|
||||
|
||||
'&.block': { display: 'flex', width: '100%' },
|
||||
|
||||
'&.circle': {
|
||||
borderRadius: '50%',
|
||||
},
|
||||
'&.round': {
|
||||
borderRadius: '14px',
|
||||
},
|
||||
// size
|
||||
'&.large': {
|
||||
height: '32px',
|
||||
},
|
||||
'&.round.large': {
|
||||
borderRadius: '16px',
|
||||
},
|
||||
'&.extraLarge': {
|
||||
height: '40px',
|
||||
},
|
||||
'&.round.extraLarge': {
|
||||
borderRadius: '20px',
|
||||
},
|
||||
|
||||
// type
|
||||
'&.plain': {
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
borderColor: 'transparent',
|
||||
background: 'transparent',
|
||||
},
|
||||
|
||||
'&.primary': {
|
||||
color: 'var(--affine-pure-white)',
|
||||
background: 'var(--affine-primary-color)',
|
||||
borderColor: 'var(--affine-black-10)',
|
||||
boxShadow: 'var(--affine-button-inner-shadow)',
|
||||
},
|
||||
'&.primary:not(.without-hover):hover': {
|
||||
background:
|
||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-primary-color)',
|
||||
},
|
||||
'&.primary.disabled': {
|
||||
opacity: '.4',
|
||||
cursor: 'default',
|
||||
},
|
||||
'&.primary.disabled:not(.without-hover):hover': {
|
||||
background: 'var(--affine-primary-color)',
|
||||
},
|
||||
|
||||
'&.error': {
|
||||
color: 'var(--affine-pure-white)',
|
||||
background: 'var(--affine-error-color)',
|
||||
borderColor: 'var(--affine-black-10)',
|
||||
boxShadow: 'var(--affine-button-inner-shadow)',
|
||||
},
|
||||
'&.error:not(.without-hover):hover': {
|
||||
background:
|
||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-error-color)',
|
||||
},
|
||||
'&.error.disabled': {
|
||||
opacity: '.4',
|
||||
cursor: 'default',
|
||||
},
|
||||
'&.error.disabled:not(.without-hover):hover': {
|
||||
background: 'var(--affine-error-color)',
|
||||
},
|
||||
|
||||
'&.warning': {
|
||||
color: 'var(--affine-white)',
|
||||
background: 'var(--affine-warning-color)',
|
||||
borderColor: 'var(--affine-black-10)',
|
||||
boxShadow: 'var(--affine-button-inner-shadow)',
|
||||
},
|
||||
'&.warning:not(.without-hover):hover': {
|
||||
background:
|
||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-warning-color)',
|
||||
},
|
||||
'&.warning.disabled': {
|
||||
opacity: '.4',
|
||||
cursor: 'default',
|
||||
},
|
||||
'&.warning.disabled:not(.without-hover):hover': {
|
||||
background: 'var(--affine-warning-color)',
|
||||
},
|
||||
|
||||
'&.success': {
|
||||
color: 'var(--affine-pure-white)',
|
||||
background: 'var(--affine-success-color)',
|
||||
borderColor: 'var(--affine-black-10)',
|
||||
boxShadow: 'var(--affine-button-inner-shadow)',
|
||||
},
|
||||
'&.success:not(.without-hover):hover': {
|
||||
background:
|
||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-success-color)',
|
||||
},
|
||||
'&.success.disabled': {
|
||||
opacity: '.4',
|
||||
cursor: 'default',
|
||||
},
|
||||
'&.success.disabled:not(.without-hover):hover': {
|
||||
background: 'var(--affine-success-color)',
|
||||
},
|
||||
|
||||
'&.processing': {
|
||||
color: 'var(--affine-pure-white)',
|
||||
background: 'var(--affine-processing-color)',
|
||||
borderColor: 'var(--affine-black-10)',
|
||||
boxShadow: 'var(--affine-button-inner-shadow)',
|
||||
},
|
||||
'&.processing:not(.without-hover):hover': {
|
||||
background:
|
||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-processing-color)',
|
||||
},
|
||||
'&.processing.disabled': {
|
||||
opacity: '.4',
|
||||
cursor: 'default',
|
||||
},
|
||||
'&.processing.disabled:not(.without-hover):hover': {
|
||||
background: 'var(--affine-processing-color)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`${button} > span`, {
|
||||
// flex: 1,
|
||||
lineHeight: 1,
|
||||
padding: '0 4px',
|
||||
});
|
||||
|
||||
export const buttonIcon = style({
|
||||
flexShrink: 0,
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
color: 'var(--affine-icon-color)',
|
||||
fontSize: '16px',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
selectors: {
|
||||
'&.start': {
|
||||
marginRight: '4px',
|
||||
},
|
||||
'&.end': {
|
||||
marginLeft: '4px',
|
||||
},
|
||||
'&.large': {
|
||||
fontSize: '20px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
},
|
||||
'&.extraLarge': {
|
||||
fontSize: '20px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
},
|
||||
'&.color-white': {
|
||||
color: 'var(--affine-white)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const iconButton = style({
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
touchAction: 'manipulation',
|
||||
outline: '0',
|
||||
border: '1px solid',
|
||||
borderRadius: '4px',
|
||||
transition: 'all .3s',
|
||||
['WebkitAppRegion' as string]: 'no-drag',
|
||||
|
||||
// changeable
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
fontSize: '20px',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
borderColor: 'var(--affine-border-color)',
|
||||
selectors: {
|
||||
'&.without-padding': {
|
||||
margin: '-2px',
|
||||
},
|
||||
'&.active': {
|
||||
color: 'var(--affine-primary-color)',
|
||||
},
|
||||
|
||||
'&:not(.without-hover):hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
'&.disabled': {
|
||||
opacity: '.4',
|
||||
cursor: 'default',
|
||||
color: 'var(--affine-disable-color)',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&.loading': {
|
||||
cursor: 'default',
|
||||
color: 'var(--affine-disable-color)',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&.disabled:not(.without-hover):hover, &.loading:not(.without-hover):hover':
|
||||
{
|
||||
background: 'inherit',
|
||||
},
|
||||
|
||||
// size
|
||||
'&.large': {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
fontSize: '24px',
|
||||
},
|
||||
'&.large.without-padding': {
|
||||
margin: '-4px',
|
||||
},
|
||||
'&.small': { width: '20px', height: '20px', fontSize: '16px' },
|
||||
'&.extra-small': { width: '16px', height: '16px', fontSize: '12px' },
|
||||
|
||||
// type
|
||||
'&.plain': {
|
||||
color: 'var(--affine-icon-color)',
|
||||
borderColor: 'transparent',
|
||||
background: 'transparent',
|
||||
},
|
||||
'&.plain.active': {
|
||||
color: 'var(--affine-primary-color)',
|
||||
},
|
||||
|
||||
'&.primary': {
|
||||
color: 'var(--affine-white)',
|
||||
background: 'var(--affine-primary-color)',
|
||||
borderColor: 'var(--affine-black-10)',
|
||||
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
|
||||
},
|
||||
'&.primary:not(.without-hover):hover': {
|
||||
background:
|
||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-primary-color)',
|
||||
},
|
||||
'&.primary.disabled': {
|
||||
opacity: '.4',
|
||||
cursor: 'default',
|
||||
},
|
||||
'&.primary.disabled:not(.without-hover):hover': {
|
||||
background: 'var(--affine-primary-color)',
|
||||
},
|
||||
|
||||
'&.error': {
|
||||
color: 'var(--affine-white)',
|
||||
background: 'var(--affine-error-color)',
|
||||
borderColor: 'var(--affine-black-10)',
|
||||
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
|
||||
},
|
||||
'&.error:not(.without-hover):hover': {
|
||||
background:
|
||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-error-color)',
|
||||
},
|
||||
'&.error.disabled': {
|
||||
opacity: '.4',
|
||||
cursor: 'default',
|
||||
},
|
||||
'&.error.disabled:not(.without-hover):hover': {
|
||||
background: 'var(--affine-error-color)',
|
||||
},
|
||||
|
||||
'&.warning': {
|
||||
color: 'var(--affine-white)',
|
||||
background: 'var(--affine-warning-color)',
|
||||
borderColor: 'var(--affine-black-10)',
|
||||
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
|
||||
},
|
||||
'&.warning:not(.without-hover):hover': {
|
||||
background:
|
||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-warning-color)',
|
||||
},
|
||||
'&.warning.disabled': {
|
||||
opacity: '.4',
|
||||
cursor: 'default',
|
||||
},
|
||||
'&.warning.disabled:not(.without-hover):hover': {
|
||||
background: 'var(--affine-warning-color)',
|
||||
},
|
||||
|
||||
'&.success': {
|
||||
color: 'var(--affine-white)',
|
||||
background: 'var(--affine-success-color)',
|
||||
borderColor: 'var(--affine-black-10)',
|
||||
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
|
||||
},
|
||||
'&.success:not(.without-hover):hover': {
|
||||
background:
|
||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-success-color)',
|
||||
},
|
||||
'&.success.disabled': {
|
||||
opacity: '.4',
|
||||
cursor: 'default',
|
||||
},
|
||||
'&.success.disabled:not(.without-hover):hover': {
|
||||
background: 'var(--affine-success-color)',
|
||||
},
|
||||
|
||||
'&.processing': {
|
||||
color: 'var(--affine-white)',
|
||||
background: 'var(--affine-processing-color)',
|
||||
borderColor: 'var(--affine-black-10)',
|
||||
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
|
||||
},
|
||||
'&.processing:not(.without-hover):hover': {
|
||||
background:
|
||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-processing-color)',
|
||||
},
|
||||
'&.processing.disabled': {
|
||||
opacity: '.4',
|
||||
cursor: 'default',
|
||||
},
|
||||
'&.processing.disabled:not(.without-hover):hover': {
|
||||
background: 'var(--affine-processing-color)',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const dropdownBtn = style({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 10px',
|
||||
// fix dropdown button click area
|
||||
paddingRight: 0,
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
fontWeight: 600,
|
||||
background: 'var(--affine-button-gray-color)',
|
||||
boxShadow: 'var(--affine-float-button-shadow)',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
// width: '100%',
|
||||
height: '32px',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: 'var(--affine-hover-color-filled)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const divider = style({
|
||||
width: '0.5px',
|
||||
height: '16px',
|
||||
background: 'var(--affine-divider-color)',
|
||||
// fix dropdown button click area
|
||||
margin: '0 4px',
|
||||
marginRight: 0,
|
||||
});
|
||||
|
||||
export const dropdownWrapper = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingLeft: '4px',
|
||||
paddingRight: '10px',
|
||||
});
|
||||
|
||||
export const dropdownIcon = style({
|
||||
borderRadius: '4px',
|
||||
selectors: {
|
||||
[`${dropdownWrapper}:hover &`]: {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const radioButton = style({
|
||||
flexGrow: 1,
|
||||
selectors: {
|
||||
'&:not(:last-of-type)': {
|
||||
marginRight: '4px',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const radioButtonContent = style({
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '24px',
|
||||
borderRadius: '8px',
|
||||
filter: 'drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.1))',
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
fontWeight: 600,
|
||||
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
'&[data-state="checked"]': {
|
||||
background: 'var(--affine-white)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const radioUncheckedButton = style([
|
||||
radioButtonContent,
|
||||
{
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
filter: 'none',
|
||||
selectors: {
|
||||
'[data-state="checked"] > &': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const radioButtonGroup = style({
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
background: 'var(--affine-hover-color-filled)',
|
||||
borderRadius: '10px',
|
||||
padding: '2px',
|
||||
// @ts-expect-error - fix electron drag
|
||||
WebkitAppRegion: 'no-drag',
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { displayInlineFlex, styled } from '../../styles';
|
||||
import type { ButtonProps } from './interface';
|
||||
import { getButtonColors, getSize } from './utils';
|
||||
|
||||
export const StyledButton = styled('button', {
|
||||
shouldForwardProp: prop => {
|
||||
return ![
|
||||
'hoverBackground',
|
||||
'shape',
|
||||
'hoverColor',
|
||||
'hoverStyle',
|
||||
'type',
|
||||
'bold',
|
||||
'noBorder',
|
||||
].includes(prop as string);
|
||||
},
|
||||
})<
|
||||
Pick<
|
||||
ButtonProps,
|
||||
| 'size'
|
||||
| 'disabled'
|
||||
| 'hoverBackground'
|
||||
| 'hoverColor'
|
||||
| 'hoverStyle'
|
||||
| 'shape'
|
||||
| 'type'
|
||||
| 'bold'
|
||||
| 'noBorder'
|
||||
>
|
||||
>(({
|
||||
theme,
|
||||
size = 'default',
|
||||
disabled,
|
||||
hoverBackground,
|
||||
hoverColor,
|
||||
hoverStyle,
|
||||
bold = false,
|
||||
shape = 'default',
|
||||
type = 'default',
|
||||
noBorder = false,
|
||||
}) => {
|
||||
const { fontSize, borderRadius, padding, height } = getSize(size);
|
||||
|
||||
return {
|
||||
height,
|
||||
paddingLeft: padding,
|
||||
paddingRight: padding,
|
||||
border: noBorder ? 'none' : '1px solid',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
...displayInlineFlex('center', 'center'),
|
||||
gap: '8px',
|
||||
position: 'relative',
|
||||
// TODO: disabled color is not decided
|
||||
...(disabled
|
||||
? {
|
||||
cursor: 'not-allowed',
|
||||
pointerEvents: 'none',
|
||||
color: 'var(--affine-text-disable-color)',
|
||||
}
|
||||
: {}),
|
||||
// TODO: Implement circle shape
|
||||
borderRadius: shape === 'default' ? borderRadius : height / 2,
|
||||
fontSize,
|
||||
fontWeight: bold ? '500' : '400',
|
||||
'.affine-button-icon': {
|
||||
color: 'var(--affine-icon-color)',
|
||||
},
|
||||
'.affine-button-icon__fixed': {
|
||||
color: 'var(--affine-icon-color)',
|
||||
},
|
||||
'>span': {
|
||||
width: 'max-content',
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
...getButtonColors(theme, type, disabled, {
|
||||
hoverBackground,
|
||||
hoverColor,
|
||||
hoverStyle,
|
||||
}),
|
||||
|
||||
// TODO: disabled hover should be implemented
|
||||
//
|
||||
// ':hover': {
|
||||
// color: hoverColor ?? 'var(--affine-primary-color)',
|
||||
// background: hoverBackground ?? 'var(--affine-hover-color)',
|
||||
// '.affine-button-icon':{
|
||||
//
|
||||
// }
|
||||
// ...(hoverStyle ?? {}),
|
||||
// },
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Theme } from '@mui/material';
|
||||
|
||||
import type { ButtonProps } from './interface';
|
||||
import { SIZE_DEFAULT, SIZE_MIDDLE, SIZE_SMALL } from './interface';
|
||||
|
||||
// TODO: Designer is not sure about the size, Now, is just use default size
|
||||
export const SIZE_CONFIG = {
|
||||
[SIZE_SMALL]: {
|
||||
iconSize: 16,
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
borderRadius: 8,
|
||||
height: 28,
|
||||
padding: 12,
|
||||
},
|
||||
[SIZE_MIDDLE]: {
|
||||
iconSize: 20,
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
borderRadius: 8,
|
||||
height: 32,
|
||||
padding: 12,
|
||||
},
|
||||
[SIZE_DEFAULT]: {
|
||||
iconSize: 24,
|
||||
fontSize: 'var(--affine-font-base)',
|
||||
height: 38,
|
||||
padding: 24,
|
||||
borderRadius: 8,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const getSize = (
|
||||
size: typeof SIZE_SMALL | typeof SIZE_MIDDLE | typeof SIZE_DEFAULT
|
||||
) => {
|
||||
return SIZE_CONFIG[size];
|
||||
};
|
||||
|
||||
export const getButtonColors = (
|
||||
_theme: Theme,
|
||||
type: ButtonProps['type'],
|
||||
disabled: boolean,
|
||||
extend?: {
|
||||
hoverBackground: ButtonProps['hoverBackground'];
|
||||
hoverColor: ButtonProps['hoverColor'];
|
||||
hoverStyle: ButtonProps['hoverStyle'];
|
||||
}
|
||||
) => {
|
||||
switch (type) {
|
||||
case 'primary':
|
||||
return {
|
||||
background: 'var(--affine-primary-color)',
|
||||
color: 'var(--affine-white)',
|
||||
borderColor: 'var(--affine-primary-color)',
|
||||
backgroundBlendMode: 'overlay',
|
||||
opacity: disabled ? '.4' : '1',
|
||||
'.affine-button-icon': {
|
||||
color: 'var(--affine-white)',
|
||||
},
|
||||
':hover': {
|
||||
background:
|
||||
'linear-gradient(var(--affine-primary-color),var(--affine-primary-color)),var(--affine-hover-color)',
|
||||
},
|
||||
};
|
||||
case 'light':
|
||||
return {
|
||||
background: 'var(--affine-tertiary-color)',
|
||||
color: disabled
|
||||
? 'var(--affine-text-disable-color)'
|
||||
: 'var(--affine-text-emphasis-color)',
|
||||
borderColor: 'var(--affine-tertiary-color)',
|
||||
'.affine-button-icon': {
|
||||
borderColor: 'var(--affine-text-emphasis-color)',
|
||||
},
|
||||
':hover': {
|
||||
borderColor: disabled
|
||||
? 'var(--affine-disable-color)'
|
||||
: 'var(--affine-text-emphasis-color)',
|
||||
},
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
background: 'var(--affine-background-warning-color)',
|
||||
color: 'var(--affine-warning-color)',
|
||||
borderColor: 'var(--affine-background-warning-color)',
|
||||
'.affine-button-icon': {
|
||||
color: 'var(--affine-warning-color)',
|
||||
},
|
||||
':hover': {
|
||||
borderColor: 'var(--affine-warning-color)',
|
||||
color: extend?.hoverColor,
|
||||
background: extend?.hoverBackground,
|
||||
...extend?.hoverStyle,
|
||||
},
|
||||
};
|
||||
case 'danger':
|
||||
return {
|
||||
background: 'var(--affine-background-error-color)',
|
||||
color: 'var(--affine-error-color)',
|
||||
borderColor: 'var(--affine-background-error-color)',
|
||||
'.affine-button-icon': {
|
||||
color: 'var(--affine-error-color)',
|
||||
},
|
||||
':hover': {
|
||||
borderColor: 'var(--affine-error-color)',
|
||||
color: extend?.hoverColor,
|
||||
background: extend?.hoverBackground,
|
||||
...extend?.hoverStyle,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
borderColor: 'var(--affine-border-color)',
|
||||
':hover': {
|
||||
borderColor: 'var(--affine-primary-color)',
|
||||
color: extend?.hoverColor ?? 'var(--affine-primary-color)',
|
||||
'.affine-button-icon': {
|
||||
color: extend?.hoverColor ?? 'var(--affine-primary-color)',
|
||||
background: extend?.hoverBackground,
|
||||
...extend?.hoverStyle,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
export const EmptySvg = memo(function EmptySvg() {
|
||||
return (
|
||||
<svg
|
||||
width="248"
|
||||
height="216"
|
||||
viewBox="0 0 248 216"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M124.321 4.45459L5.2627 210.669H243.379L124.321 4.45459ZM239.666 207.565L182.825 109.114L125.984 141.931L239.666 207.565ZM125.153 140.49L181.993 107.673L125.153 9.22248L125.153 140.49ZM123.489 9.22248L123.489 140.49L66.6484 107.673L123.489 9.22248ZM65.8166 109.114L8.97592 207.565L122.657 141.931L65.8166 109.114ZM123.489 143.372L9.80773 209.006H123.489V143.372ZM125.153 209.006H238.834L125.153 143.372L125.153 209.006Z"
|
||||
fillOpacity="0.3"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M124.283 208.367C161.062 208.367 190.89 178.415 190.89 141.452C190.89 104.489 161.062 74.5371 124.283 74.5371C87.5044 74.5371 57.6765 104.489 57.6765 141.452C57.6765 178.415 87.5044 208.367 124.283 208.367ZM124.283 210.031C161.988 210.031 192.553 179.327 192.553 141.452C192.553 103.577 161.988 72.8735 124.283 72.8735C86.5785 72.8735 56.0129 103.577 56.0129 141.452C56.0129 179.327 86.5785 210.031 124.283 210.031Z"
|
||||
fillOpacity="0.3"
|
||||
/>
|
||||
<circle cx="65.7267" cy="107.881" r="4.93369" fillOpacity="0.8" />
|
||||
<circle cx="5.26255" cy="210.014" r="4.93369" fillOpacity="0.8" />
|
||||
<circle cx="124.359" cy="210.014" r="4.93369" fillOpacity="0.8" />
|
||||
<circle cx="243.06" cy="210.014" r="4.93369" fillOpacity="0.8" />
|
||||
<circle cx="183.499" cy="107.881" r="4.93369" fillOpacity="0.8" />
|
||||
<circle cx="124.396" cy="141.83" r="4.93369" fillOpacity="0.8" />
|
||||
<circle cx="124.344" cy="5.00449" r="4.93369" fillOpacity="0.8" />
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import { EmptySvg } from './empty-svg';
|
||||
import { StyledEmptyContainer } from './style';
|
||||
export type EmptyContentProps = {
|
||||
containerStyle?: CSSProperties;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
descriptionStyle?: CSSProperties;
|
||||
};
|
||||
|
||||
export const Empty = ({
|
||||
containerStyle,
|
||||
title,
|
||||
description,
|
||||
descriptionStyle,
|
||||
}: EmptyContentProps) => {
|
||||
return (
|
||||
<StyledEmptyContainer style={containerStyle}>
|
||||
<div style={{ color: 'var(--affine-black)' }}>
|
||||
<EmptySvg />
|
||||
</div>
|
||||
{title && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: '30px',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
{description && (
|
||||
<p style={{ marginTop: title ? '8px' : '30px', ...descriptionStyle }}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</StyledEmptyContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Empty;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './empty';
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { displayFlex, styled } from '../../styles';
|
||||
|
||||
export const StyledEmptyContainer = styled('div')<{ style?: CSSProperties }>(({
|
||||
style,
|
||||
}) => {
|
||||
return {
|
||||
height: '100%',
|
||||
...displayFlex('center', 'center'),
|
||||
flexDirection: 'column',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
svg: {
|
||||
width: style?.width ?? '248px',
|
||||
height: style?.height ?? '216px',
|
||||
fontSize: style?.fontSize ?? 'inherit',
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect } from '@storybook/jest';
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { userEvent, within } from '@storybook/testing-library';
|
||||
|
||||
import { Input } from '.';
|
||||
|
||||
export default {
|
||||
title: 'AFFiNE/Input',
|
||||
component: Input,
|
||||
} satisfies Meta<typeof Input>;
|
||||
|
||||
export const Basic: StoryFn<typeof Input> = () => {
|
||||
return <Input data-testid="test-input" defaultValue="test" />;
|
||||
};
|
||||
|
||||
Basic.play = async ({ canvasElement }) => {
|
||||
const element = within(canvasElement);
|
||||
const item = element.getByTestId('test-input') as HTMLInputElement;
|
||||
expect(item).toBeTruthy();
|
||||
expect(item.value).toBe('test');
|
||||
userEvent.clear(item);
|
||||
userEvent.type(item, 'test 2');
|
||||
expect(item.value).toBe('test 2');
|
||||
};
|
||||
|
||||
export const DynamicHeight: StoryFn<typeof Input> = () => {
|
||||
return <Input width={200} data-testid="test-input" />;
|
||||
};
|
||||
|
||||
DynamicHeight.play = async ({ canvasElement }) => {
|
||||
const element = within(canvasElement);
|
||||
const item = element.getByTestId('test-input') as HTMLInputElement;
|
||||
expect(item).toBeTruthy();
|
||||
expect(item.getBoundingClientRect().width).toBe(200);
|
||||
};
|
||||
|
||||
export const NoBorder: StoryFn<typeof Input> = () => {
|
||||
return <Input noBorder={true} data-testid="test-input" />;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './input';
|
||||
import { Input } from './input';
|
||||
export default Input;
|
||||
@@ -0,0 +1,123 @@
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import clsx from 'clsx';
|
||||
import type {
|
||||
ChangeEvent,
|
||||
CSSProperties,
|
||||
FocusEvent,
|
||||
FocusEventHandler,
|
||||
ForwardedRef,
|
||||
InputHTMLAttributes,
|
||||
KeyboardEvent,
|
||||
KeyboardEventHandler,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { forwardRef, useCallback, useState } from 'react';
|
||||
|
||||
import { input, inputWrapper, widthVar } from './style.css';
|
||||
|
||||
export type InputProps = {
|
||||
disabled?: boolean;
|
||||
width?: CSSProperties['width'];
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
|
||||
noBorder?: boolean;
|
||||
status?: 'error' | 'success' | 'warning' | 'default';
|
||||
size?: 'default' | 'large' | 'extraLarge';
|
||||
preFix?: ReactNode;
|
||||
endFix?: ReactNode;
|
||||
type?: HTMLInputElement['type'];
|
||||
inputStyle?: CSSProperties;
|
||||
onEnter?: () => void;
|
||||
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size'>;
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{
|
||||
disabled,
|
||||
width,
|
||||
onChange: propsOnChange,
|
||||
noBorder = false,
|
||||
className,
|
||||
status = 'default',
|
||||
style = {},
|
||||
inputStyle = {},
|
||||
size = 'default',
|
||||
onFocus,
|
||||
onBlur,
|
||||
preFix,
|
||||
endFix,
|
||||
onEnter,
|
||||
onKeyDown,
|
||||
...otherProps
|
||||
}: InputProps,
|
||||
ref: ForwardedRef<HTMLInputElement>
|
||||
) {
|
||||
const [isFocus, setIsFocus] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(inputWrapper, className, {
|
||||
// status
|
||||
disabled: disabled,
|
||||
'no-border': noBorder,
|
||||
focus: isFocus,
|
||||
// color
|
||||
error: status === 'error',
|
||||
success: status === 'success',
|
||||
warning: status === 'warning',
|
||||
default: status === 'default',
|
||||
// size
|
||||
large: size === 'large',
|
||||
'extra-large': size === 'extraLarge',
|
||||
})}
|
||||
style={{
|
||||
...assignInlineVars({
|
||||
[widthVar]: width ? `${width}px` : '100%',
|
||||
}),
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{preFix}
|
||||
<input
|
||||
className={clsx(input, {
|
||||
large: size === 'large',
|
||||
'extra-large': size === 'extraLarge',
|
||||
})}
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
style={inputStyle}
|
||||
onFocus={useCallback(
|
||||
(e: FocusEvent<HTMLInputElement>) => {
|
||||
setIsFocus(true);
|
||||
onFocus?.(e);
|
||||
},
|
||||
[onFocus]
|
||||
)}
|
||||
onBlur={useCallback(
|
||||
(e: FocusEvent<HTMLInputElement>) => {
|
||||
setIsFocus(false);
|
||||
onBlur?.(e);
|
||||
},
|
||||
[onBlur]
|
||||
)}
|
||||
onChange={useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
propsOnChange?.(e.target.value);
|
||||
},
|
||||
[propsOnChange]
|
||||
)}
|
||||
onKeyDown={useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
onEnter?.();
|
||||
}
|
||||
onKeyDown?.(e);
|
||||
},
|
||||
[onKeyDown, onEnter]
|
||||
)}
|
||||
{...otherProps}
|
||||
/>
|
||||
{endFix}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createVar, style } from '@vanilla-extract/css';
|
||||
|
||||
export const widthVar = createVar('widthVar');
|
||||
|
||||
export const inputWrapper = style({
|
||||
vars: {
|
||||
[widthVar]: '100%',
|
||||
},
|
||||
width: widthVar,
|
||||
height: 28,
|
||||
padding: '4px 10px',
|
||||
color: 'var(--affine-icon-color)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
backgroundColor: 'var(--affine-white-10)',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
// icon size
|
||||
fontSize: '16px',
|
||||
|
||||
selectors: {
|
||||
'&.no-border': {
|
||||
border: 'unset',
|
||||
},
|
||||
// size
|
||||
'&.large': {
|
||||
height: 32,
|
||||
// icon size
|
||||
fontSize: '20px',
|
||||
},
|
||||
'&.extra-large': {
|
||||
height: 40,
|
||||
padding: '8px 10px',
|
||||
// icon size
|
||||
fontSize: '20px',
|
||||
},
|
||||
// color
|
||||
'&.disabled': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
'&.error': {
|
||||
borderColor: 'var(--affine-error-color)',
|
||||
},
|
||||
'&.success': {
|
||||
borderColor: 'var(--affine-success-color)',
|
||||
},
|
||||
'&.warning': {
|
||||
borderColor: 'var(--affine-warning-color)',
|
||||
},
|
||||
'&.default.focus': {
|
||||
borderColor: 'var(--affine-primary-color)',
|
||||
boxShadow: 'var(--affine-active-shadow)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const input = style({
|
||||
width: '0',
|
||||
flex: 1,
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
lineHeight: '20px',
|
||||
fontWeight: '500',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
boxSizing: 'border-box',
|
||||
// prevent default style
|
||||
WebkitAppearance: 'none',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
outline: 'none',
|
||||
border: 'none',
|
||||
|
||||
selectors: {
|
||||
'&::placeholder': {
|
||||
color: 'var(--affine-placeholder-color)',
|
||||
},
|
||||
'&:disabled': {
|
||||
color: 'var(--affine-text-disable-color)',
|
||||
},
|
||||
'&.large, &.extra-large': {
|
||||
fontSize: 'var(--affine-font-base)',
|
||||
lineHeight: '24px',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { styled, textEllipsis } from '../../styles';
|
||||
|
||||
// This component should be just used to be contained the text content
|
||||
export type ContentProps = {
|
||||
width?: CSSProperties['width'];
|
||||
maxWidth?: CSSProperties['maxWidth'];
|
||||
align?: CSSProperties['textAlign'];
|
||||
color?: CSSProperties['color'];
|
||||
fontSize?: CSSProperties['fontSize'];
|
||||
weight?: CSSProperties['fontWeight'];
|
||||
lineHeight?: CSSProperties['lineHeight'];
|
||||
ellipsis?: boolean;
|
||||
lineNum?: number;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
export const Content = styled('div', {
|
||||
shouldForwardProp: prop => {
|
||||
return ![
|
||||
'color',
|
||||
'fontSize',
|
||||
'weight',
|
||||
'lineHeight',
|
||||
'ellipsis',
|
||||
'lineNum',
|
||||
'width',
|
||||
'maxWidth',
|
||||
'align',
|
||||
].includes(prop as string);
|
||||
},
|
||||
})<ContentProps>(({
|
||||
color,
|
||||
fontSize,
|
||||
weight,
|
||||
lineHeight,
|
||||
ellipsis,
|
||||
lineNum,
|
||||
width,
|
||||
maxWidth,
|
||||
align,
|
||||
}) => {
|
||||
return {
|
||||
width,
|
||||
maxWidth,
|
||||
textAlign: align,
|
||||
display: 'inline-block',
|
||||
color: color ?? 'var(--affine-text-primary-color)',
|
||||
fontSize: fontSize ?? 'var(--affine-font-base)',
|
||||
fontWeight: weight ?? 400,
|
||||
lineHeight: lineHeight ?? 1.5,
|
||||
...(ellipsis ? textEllipsis(lineNum) : {}),
|
||||
};
|
||||
});
|
||||
|
||||
export default Content;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './content';
|
||||
export * from './wrapper';
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { styled } from '../../styles';
|
||||
|
||||
export type WrapperProps = {
|
||||
display?: CSSProperties['display'];
|
||||
width?: CSSProperties['width'];
|
||||
height?: CSSProperties['height'];
|
||||
padding?: CSSProperties['padding'];
|
||||
paddingTop?: CSSProperties['paddingTop'];
|
||||
paddingRight?: CSSProperties['paddingRight'];
|
||||
paddingLeft?: CSSProperties['paddingLeft'];
|
||||
paddingBottom?: CSSProperties['paddingBottom'];
|
||||
margin?: CSSProperties['margin'];
|
||||
marginTop?: CSSProperties['marginTop'];
|
||||
marginLeft?: CSSProperties['marginLeft'];
|
||||
marginRight?: CSSProperties['marginRight'];
|
||||
marginBottom?: CSSProperties['marginBottom'];
|
||||
};
|
||||
|
||||
export type FlexWrapperProps = {
|
||||
display?: CSSProperties['display'];
|
||||
flexDirection?: CSSProperties['flexDirection'];
|
||||
justifyContent?: CSSProperties['justifyContent'];
|
||||
alignItems?: CSSProperties['alignItems'];
|
||||
wrap?: boolean;
|
||||
flexShrink?: CSSProperties['flexShrink'];
|
||||
flexGrow?: CSSProperties['flexGrow'];
|
||||
};
|
||||
|
||||
// Sometimes we just want to wrap a component with a div to set flex or other styles, but we don't want to create a new component for it.
|
||||
export const Wrapper = styled('div', {
|
||||
shouldForwardProp: prop => {
|
||||
return ![
|
||||
'display',
|
||||
'width',
|
||||
'height',
|
||||
'padding',
|
||||
'margin',
|
||||
'paddingTop',
|
||||
'paddingRight',
|
||||
'paddingLeft',
|
||||
'paddingBottom',
|
||||
'marginTop',
|
||||
'marginLeft',
|
||||
'marginRight',
|
||||
'marginBottom',
|
||||
].includes(prop as string);
|
||||
},
|
||||
})<WrapperProps>(({
|
||||
display,
|
||||
width,
|
||||
height,
|
||||
padding,
|
||||
margin,
|
||||
paddingTop,
|
||||
paddingRight,
|
||||
paddingLeft,
|
||||
paddingBottom,
|
||||
marginTop,
|
||||
marginLeft,
|
||||
marginRight,
|
||||
marginBottom,
|
||||
}) => {
|
||||
return {
|
||||
display,
|
||||
width,
|
||||
height,
|
||||
padding,
|
||||
margin,
|
||||
paddingTop,
|
||||
paddingRight,
|
||||
paddingLeft,
|
||||
paddingBottom,
|
||||
marginTop,
|
||||
marginLeft,
|
||||
marginRight,
|
||||
marginBottom,
|
||||
};
|
||||
});
|
||||
|
||||
export const FlexWrapper = styled(Wrapper, {
|
||||
shouldForwardProp: prop => {
|
||||
return ![
|
||||
'justifyContent',
|
||||
'alignItems',
|
||||
'wrap',
|
||||
'flexDirection',
|
||||
'flexShrink',
|
||||
'flexGrow',
|
||||
].includes(prop as string);
|
||||
},
|
||||
})<FlexWrapperProps>(({
|
||||
justifyContent,
|
||||
alignItems,
|
||||
wrap = false,
|
||||
flexDirection,
|
||||
flexShrink,
|
||||
flexGrow,
|
||||
}) => {
|
||||
return {
|
||||
display: 'flex',
|
||||
justifyContent,
|
||||
alignItems,
|
||||
flexWrap: wrap ? 'wrap' : 'nowrap',
|
||||
flexDirection,
|
||||
flexShrink,
|
||||
flexGrow,
|
||||
};
|
||||
});
|
||||
|
||||
// TODO: Complete me
|
||||
export const GridWrapper = styled(Wrapper, {
|
||||
shouldForwardProp: prop => {
|
||||
return ![''].includes(prop as string);
|
||||
},
|
||||
})<WrapperProps>(() => {
|
||||
return {
|
||||
display: 'grid',
|
||||
};
|
||||
});
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './loading';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
|
||||
import { loading, speedVar } from './styles.css';
|
||||
|
||||
export interface LoadingProps {
|
||||
size?: number;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
export const Loading = ({ size, speed = 1.2 }: LoadingProps) => {
|
||||
return (
|
||||
<svg
|
||||
className={loading}
|
||||
viewBox="0 0 1024 1024"
|
||||
focusable="false"
|
||||
data-icon="loading"
|
||||
width={size ? `${size}px` : '.8em'}
|
||||
height={size ? `${size}px` : '.8em'}
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...assignInlineVars({
|
||||
[speedVar]: `${speed}s`,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<path d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z"></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createVar, keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
export const speedVar = createVar('speedVar');
|
||||
|
||||
const rotate = keyframes({
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'50%': { transform: 'rotate(180deg)' },
|
||||
'100%': { transform: 'rotate(360deg)' },
|
||||
});
|
||||
export const loading = style({
|
||||
vars: {
|
||||
[speedVar]: '1.5s',
|
||||
},
|
||||
textRendering: 'optimizeLegibility',
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
animation: `${rotate} ${speedVar} infinite linear`,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @deprecated
|
||||
* Use @toeverything/components/menu instead, this component only used in bookmark plugin, since it support set anchor as Range
|
||||
*/
|
||||
export * from './menu-item';
|
||||
export * from './pure-menu';
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { HTMLAttributes, PropsWithChildren, ReactElement } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import {
|
||||
StyledContent,
|
||||
StyledEndIconWrapper,
|
||||
StyledMenuItem,
|
||||
StyledStartIconWrapper,
|
||||
} from './styles';
|
||||
|
||||
export type IconMenuProps = PropsWithChildren<{
|
||||
icon?: ReactElement;
|
||||
endIcon?: ReactElement;
|
||||
iconSize?: number;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
disableHover?: boolean;
|
||||
userFocused?: boolean;
|
||||
gap?: string;
|
||||
fontSize?: string;
|
||||
}> &
|
||||
HTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
export const MenuItem = forwardRef<HTMLButtonElement, IconMenuProps>(
|
||||
({ endIcon, icon, children, gap, fontSize, iconSize, ...props }, ref) => {
|
||||
return (
|
||||
<StyledMenuItem ref={ref} {...props}>
|
||||
{icon && (
|
||||
<StyledStartIconWrapper iconSize={iconSize} gap={gap}>
|
||||
{icon}
|
||||
</StyledStartIconWrapper>
|
||||
)}
|
||||
<StyledContent fontSize={fontSize}>{children}</StyledContent>
|
||||
{endIcon && (
|
||||
<StyledEndIconWrapper iconSize={iconSize} gap={gap}>
|
||||
{endIcon}
|
||||
</StyledEndIconWrapper>
|
||||
)}
|
||||
</StyledMenuItem>
|
||||
);
|
||||
}
|
||||
);
|
||||
MenuItem.displayName = 'MenuItem';
|
||||
export default MenuItem;
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import type { PurePopperProps } from '../popper';
|
||||
import { PurePopper } from '../popper';
|
||||
import { StyledMenuWrapper } from './styles';
|
||||
|
||||
export type PureMenuProps = PurePopperProps & {
|
||||
width?: CSSProperties['width'];
|
||||
height?: CSSProperties['height'];
|
||||
};
|
||||
export const PureMenu = ({
|
||||
children,
|
||||
placement,
|
||||
width,
|
||||
...otherProps
|
||||
}: PureMenuProps) => {
|
||||
return (
|
||||
<PurePopper placement={placement} {...otherProps}>
|
||||
<StyledMenuWrapper width={width} placement={placement}>
|
||||
{children}
|
||||
</StyledMenuWrapper>
|
||||
</PurePopper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { displayFlex, styled, textEllipsis } from '../../styles';
|
||||
import StyledPopperContainer from '../shared/container';
|
||||
|
||||
export const StyledMenuWrapper = styled(StyledPopperContainer, {
|
||||
shouldForwardProp: propName =>
|
||||
!['width', 'height'].includes(propName as string),
|
||||
})<{
|
||||
width?: CSSProperties['width'];
|
||||
height?: CSSProperties['height'];
|
||||
}>(({ width, height }) => {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
minWidth: '200px',
|
||||
background: 'var(--affine-white)',
|
||||
padding: '8px 4px',
|
||||
fontSize: '14px',
|
||||
backgroundColor: 'var(--affine-white)',
|
||||
boxShadow: 'var(--affine-menu-shadow)',
|
||||
userSelect: 'none',
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledStartIconWrapper = styled('div')<{
|
||||
gap?: CSSProperties['gap'];
|
||||
iconSize?: CSSProperties['fontSize'];
|
||||
}>(({ gap, iconSize }) => {
|
||||
return {
|
||||
display: 'flex',
|
||||
marginRight: gap ? gap : '12px',
|
||||
fontSize: iconSize ? iconSize : '20px',
|
||||
color: 'var(--affine-icon-color)',
|
||||
};
|
||||
});
|
||||
export const StyledEndIconWrapper = styled('div')<{
|
||||
gap?: CSSProperties['gap'];
|
||||
iconSize?: CSSProperties['fontSize'];
|
||||
}>(({ gap, iconSize }) => {
|
||||
return {
|
||||
display: 'flex',
|
||||
marginLeft: gap ? gap : '12px',
|
||||
fontSize: iconSize ? iconSize : '20px',
|
||||
color: 'var(--affine-icon-color)',
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledContent = styled('div')<{
|
||||
fontSize?: CSSProperties['fontSize'];
|
||||
}>(({ fontSize }) => {
|
||||
return {
|
||||
textAlign: 'left',
|
||||
flexGrow: 1,
|
||||
fontSize: fontSize ? fontSize : 'var(--affine-font-base)',
|
||||
...textEllipsis(1),
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledMenuItem = styled('button')<{
|
||||
isDir?: boolean;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
disableHover?: boolean;
|
||||
userFocused?: boolean;
|
||||
}>(({
|
||||
isDir = false,
|
||||
disabled = false,
|
||||
active = false,
|
||||
disableHover = false,
|
||||
userFocused = false,
|
||||
}) => {
|
||||
return {
|
||||
width: '100%',
|
||||
borderRadius: '5px',
|
||||
padding: '0 14px',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
height: '32px',
|
||||
...displayFlex('flex-start', 'center'),
|
||||
cursor: isDir ? 'pointer' : '',
|
||||
position: 'relative',
|
||||
backgroundColor: 'transparent',
|
||||
color: disabled
|
||||
? 'var(--affine-text-disable-color)'
|
||||
: 'var(--affine-text-primary-color)',
|
||||
svg: {
|
||||
color: disabled
|
||||
? 'var(--affine-text-disable-color)'
|
||||
: 'var(--affine-icon-color)',
|
||||
},
|
||||
...(disabled
|
||||
? {
|
||||
cursor: 'not-allowed',
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
: {}),
|
||||
|
||||
':hover':
|
||||
disabled || disableHover
|
||||
? {}
|
||||
: {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
...(userFocused && !disabled
|
||||
? {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
}
|
||||
: {}),
|
||||
...(active && !disabled
|
||||
? {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ClickAwayListener as MuiClickAwayListener } from '@mui/base/ClickAwayListener';
|
||||
import MuiAvatar from '@mui/material/Avatar';
|
||||
import MuiBreadcrumbs from '@mui/material/Breadcrumbs';
|
||||
import MuiCollapse from '@mui/material/Collapse';
|
||||
import MuiFade from '@mui/material/Fade';
|
||||
import MuiGrow from '@mui/material/Grow';
|
||||
import MuiSkeleton from '@mui/material/Skeleton';
|
||||
import MuiSlide from '@mui/material/Slide';
|
||||
|
||||
export {
|
||||
MuiAvatar,
|
||||
MuiBreadcrumbs,
|
||||
MuiClickAwayListener,
|
||||
MuiCollapse,
|
||||
MuiFade,
|
||||
MuiGrow,
|
||||
MuiSkeleton,
|
||||
MuiSlide,
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './interface';
|
||||
export * from './popper';
|
||||
export * from './pure-popper';
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
type PopperPlacementType,
|
||||
type PopperProps as PopperUnstyledProps,
|
||||
} from '@mui/base/Popper';
|
||||
import type { CSSProperties, ReactElement, ReactNode, Ref } from 'react';
|
||||
export type VirtualElement = {
|
||||
getBoundingClientRect: () => ClientRect | DOMRect;
|
||||
contextElement?: Element;
|
||||
};
|
||||
|
||||
export type PopperHandler = {
|
||||
setVisible: (visible: boolean) => void;
|
||||
};
|
||||
|
||||
export type PopperArrowProps = {
|
||||
placement?: PopperPlacementType;
|
||||
};
|
||||
|
||||
export type PopperProps = {
|
||||
// Popover content
|
||||
content?: ReactNode;
|
||||
|
||||
// Popover trigger
|
||||
children: ReactElement;
|
||||
|
||||
// Whether the default is implicit
|
||||
defaultVisible?: boolean;
|
||||
|
||||
// Used to manually control the visibility of the Popover
|
||||
visible?: boolean;
|
||||
|
||||
// TODO: support focus
|
||||
trigger?: 'hover' | 'click' | 'focus' | ('click' | 'hover' | 'focus')[];
|
||||
|
||||
// How long does it take for the mouse to display the Popover, in milliseconds
|
||||
pointerEnterDelay?: number;
|
||||
|
||||
// How long does it take to hide the Popover after the mouse moves out, in milliseconds
|
||||
pointerLeaveDelay?: number;
|
||||
|
||||
// Callback fired when the component closed or open
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
|
||||
// Popover container style
|
||||
popoverStyle?: CSSProperties;
|
||||
|
||||
// Popover container class name
|
||||
popoverClassName?: string;
|
||||
|
||||
// Anchor class name
|
||||
anchorClassName?: string;
|
||||
|
||||
// Popover z-index
|
||||
zIndex?: number;
|
||||
|
||||
offset?: [number, number];
|
||||
|
||||
showArrow?: boolean;
|
||||
|
||||
popperHandlerRef?: Ref<PopperHandler>;
|
||||
|
||||
onClickAway?: () => void;
|
||||
triggerContainerStyle?: CSSProperties;
|
||||
} & Omit<PopperUnstyledProps, 'open' | 'content'>;
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { styled } from '../../styles';
|
||||
import type { PopperArrowProps } from './interface';
|
||||
|
||||
export const PopperArrow = forwardRef<HTMLElement, PopperArrowProps>(
|
||||
function PopperArrow({ placement }, ref) {
|
||||
return <StyledArrow placement={placement} ref={ref} />;
|
||||
}
|
||||
);
|
||||
|
||||
const getArrowStyle = (
|
||||
placement: PopperArrowProps['placement'] = 'bottom',
|
||||
backgroundColor: CSSProperties['backgroundColor']
|
||||
) => {
|
||||
if (placement.indexOf('bottom') === 0) {
|
||||
return {
|
||||
top: 0,
|
||||
left: 0,
|
||||
marginTop: '-0.9em',
|
||||
width: '3em',
|
||||
height: '1em',
|
||||
'&::before': {
|
||||
borderWidth: '0 1em 1em 1em',
|
||||
borderColor: `transparent transparent ${backgroundColor} transparent`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (placement.indexOf('top') === 0) {
|
||||
return {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
marginBottom: '-0.9em',
|
||||
width: '3em',
|
||||
height: '1em',
|
||||
'&::before': {
|
||||
borderWidth: '1em 1em 0 1em',
|
||||
borderColor: `${backgroundColor} transparent transparent transparent`,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (placement.indexOf('left') === 0) {
|
||||
return {
|
||||
right: 0,
|
||||
marginRight: '-0.9em',
|
||||
height: '3em',
|
||||
width: '1em',
|
||||
'&::before': {
|
||||
borderWidth: '1em 0 1em 1em',
|
||||
borderColor: `transparent transparent transparent ${backgroundColor}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (placement.indexOf('right') === 0) {
|
||||
return {
|
||||
left: 0,
|
||||
marginLeft: '-0.9em',
|
||||
height: '3em',
|
||||
width: '1em',
|
||||
'&::before': {
|
||||
borderWidth: '1em 1em 1em 0',
|
||||
borderColor: `transparent ${backgroundColor} transparent transparent`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
display: 'none',
|
||||
};
|
||||
};
|
||||
|
||||
const StyledArrow = styled('span')<{
|
||||
placement?: PopperArrowProps['placement'];
|
||||
}>(({ placement }) => {
|
||||
return {
|
||||
position: 'absolute',
|
||||
fontSize: '7px',
|
||||
width: '3em',
|
||||
'::before': {
|
||||
content: '""',
|
||||
margin: 'auto',
|
||||
display: 'block',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderStyle: 'solid',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
|
||||
...getArrowStyle(placement, 'var(--affine-tooltip)'),
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import { ClickAwayListener } from '@mui/base/ClickAwayListener';
|
||||
import { Popper as PopperUnstyled } from '@mui/base/Popper';
|
||||
import Grow from '@mui/material/Grow';
|
||||
import type { CSSProperties, PointerEvent } from 'react';
|
||||
import {
|
||||
cloneElement,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { styled } from '../../styles';
|
||||
import type { PopperProps, VirtualElement } from './interface';
|
||||
export const Popper = ({
|
||||
children,
|
||||
content,
|
||||
anchorEl: propsAnchorEl,
|
||||
placement = 'top-start',
|
||||
defaultVisible = false,
|
||||
visible: propsVisible,
|
||||
trigger = 'hover',
|
||||
pointerEnterDelay = 500,
|
||||
pointerLeaveDelay = 100,
|
||||
onVisibleChange,
|
||||
popoverStyle,
|
||||
popoverClassName,
|
||||
anchorClassName,
|
||||
zIndex,
|
||||
offset = [0, 5],
|
||||
showArrow = false,
|
||||
popperHandlerRef,
|
||||
onClick,
|
||||
onClickAway,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
triggerContainerStyle = {},
|
||||
...popperProps
|
||||
}: PopperProps) => {
|
||||
const [anchorEl, setAnchorEl] = useState<VirtualElement>();
|
||||
const [visible, setVisible] = useState(defaultVisible);
|
||||
//const [arrowRef, setArrowRef] = useState<HTMLElement>();
|
||||
const arrowRef = null;
|
||||
const pointerLeaveTimer = useRef<number>();
|
||||
const pointerEnterTimer = useRef<number>();
|
||||
|
||||
const visibleControlledByParent = typeof propsVisible !== 'undefined';
|
||||
const isAnchorCustom = typeof propsAnchorEl !== 'undefined';
|
||||
|
||||
const hasHoverTrigger = useMemo(() => {
|
||||
return (
|
||||
trigger === 'hover' ||
|
||||
(Array.isArray(trigger) && trigger.includes('hover'))
|
||||
);
|
||||
}, [trigger]);
|
||||
|
||||
const hasClickTrigger = useMemo(() => {
|
||||
return (
|
||||
trigger === 'click' ||
|
||||
(Array.isArray(trigger) && trigger.includes('click'))
|
||||
);
|
||||
}, [trigger]);
|
||||
|
||||
const onPointerEnterHandler = (e: PointerEvent<HTMLDivElement>) => {
|
||||
onPointerEnter?.(e);
|
||||
if (!hasHoverTrigger || visibleControlledByParent) {
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(pointerLeaveTimer.current);
|
||||
|
||||
pointerEnterTimer.current = window.window.setTimeout(() => {
|
||||
setVisible(true);
|
||||
}, pointerEnterDelay);
|
||||
};
|
||||
|
||||
const onPointerLeaveHandler = (e: PointerEvent<HTMLDivElement>) => {
|
||||
onPointerLeave?.(e);
|
||||
|
||||
if (!hasHoverTrigger || visibleControlledByParent) {
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(pointerEnterTimer.current);
|
||||
pointerLeaveTimer.current = window.window.setTimeout(() => {
|
||||
setVisible(false);
|
||||
}, pointerLeaveDelay);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onVisibleChange?.(visible);
|
||||
}, [visible, onVisibleChange]);
|
||||
|
||||
useImperativeHandle(popperHandlerRef, () => {
|
||||
return {
|
||||
setVisible: (visible: boolean) => {
|
||||
!visibleControlledByParent && setVisible(visible);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mergedClass = [anchorClassName, children.props.className]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<ClickAwayListener
|
||||
onClickAway={() => {
|
||||
if (visibleControlledByParent) {
|
||||
onClickAway?.();
|
||||
} else {
|
||||
setVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Container style={triggerContainerStyle}>
|
||||
{cloneElement(children, {
|
||||
ref: (dom: HTMLDivElement) => setAnchorEl(dom),
|
||||
onClick: (e: MouseEvent) => {
|
||||
children.props.onClick?.(e);
|
||||
if (!hasClickTrigger || visibleControlledByParent) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
onClick?.(e);
|
||||
return;
|
||||
}
|
||||
setVisible(!visible);
|
||||
},
|
||||
onPointerEnter: onPointerEnterHandler,
|
||||
onPointerLeave: onPointerLeaveHandler,
|
||||
...(mergedClass
|
||||
? {
|
||||
className: mergedClass,
|
||||
}
|
||||
: {}),
|
||||
})}
|
||||
{content && (
|
||||
<BasicStyledPopper
|
||||
open={visibleControlledByParent ? propsVisible : visible}
|
||||
zIndex={zIndex}
|
||||
anchorEl={isAnchorCustom ? propsAnchorEl : anchorEl}
|
||||
placement={placement}
|
||||
transition
|
||||
modifiers={[
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'arrow',
|
||||
enabled: showArrow,
|
||||
options: {
|
||||
element: arrowRef,
|
||||
},
|
||||
},
|
||||
]}
|
||||
{...popperProps}
|
||||
>
|
||||
{({ TransitionProps }) => (
|
||||
<Grow {...TransitionProps}>
|
||||
<div
|
||||
onPointerEnter={onPointerEnterHandler}
|
||||
onPointerLeave={onPointerLeaveHandler}
|
||||
style={popoverStyle}
|
||||
className={popoverClassName}
|
||||
onClick={() => {
|
||||
if (hasClickTrigger && !visibleControlledByParent) {
|
||||
setVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showArrow ? (
|
||||
<>
|
||||
{placement.indexOf('bottom') === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="11"
|
||||
height="6"
|
||||
viewBox="0 0 11 6"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M6.38889 0.45C5.94444 -0.15 5.05555 -0.150001 4.61111 0.449999L0.499999 6L10.5 6L6.38889 0.45Z"
|
||||
style={{ fill: 'var(--affine-tooltip)' }}
|
||||
/>
|
||||
</svg>
|
||||
{content}
|
||||
</div>
|
||||
) : placement.indexOf('top') === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="11"
|
||||
height="6"
|
||||
viewBox="0 0 11 6"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M4.61111 5.55C5.05556 6.15 5.94445 6.15 6.38889 5.55L10.5 -4.76837e-07H0.5L4.61111 5.55Z"
|
||||
style={{ fill: 'var(--affine-tooltip)' }}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
) : placement.indexOf('left') === 0 ? (
|
||||
<>
|
||||
{content}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="6"
|
||||
height="10"
|
||||
viewBox="0 0 6 10"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M5.55 5.88889C6.15 5.44444 6.15 4.55555 5.55 4.11111L-4.76837e-07 0L-4.76837e-07 10L5.55 5.88889Z"
|
||||
style={{ fill: 'var(--affine-tooltip)' }}
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
) : placement.indexOf('right') === 0 ? (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="6"
|
||||
height="10"
|
||||
viewBox="0 0 6 10"
|
||||
style={{ fill: 'var(--affine-tooltip)' }}
|
||||
>
|
||||
<path
|
||||
d="M0.45 4.11111C-0.15 4.55556 -0.15 5.44445 0.45 5.88889L6 10V0L0.45 4.11111Z"
|
||||
style={{ fill: 'var(--affine-tooltip)' }}
|
||||
/>
|
||||
</svg>
|
||||
{content}
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="11"
|
||||
height="6"
|
||||
viewBox="0 0 11 6"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M4.61111 5.55C5.05556 6.15 5.94445 6.15 6.38889 5.55L10.5 -4.76837e-07H0.5L4.61111 5.55Z"
|
||||
style={{ fill: 'var(--affine-tooltip)' }}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>{content}</>
|
||||
)}
|
||||
</div>
|
||||
</Grow>
|
||||
)}
|
||||
</BasicStyledPopper>
|
||||
)}
|
||||
</Container>
|
||||
</ClickAwayListener>
|
||||
);
|
||||
};
|
||||
|
||||
// The children of ClickAwayListener must be a DOM Node to judge whether the click is outside, use node.contains
|
||||
const Container = styled('div')({
|
||||
display: 'contents',
|
||||
});
|
||||
|
||||
export const BasicStyledPopper = styled(PopperUnstyled, {
|
||||
shouldForwardProp: (propName: string) =>
|
||||
!['zIndex'].some(name => name === propName),
|
||||
})<{
|
||||
zIndex?: CSSProperties['zIndex'];
|
||||
}>(({ zIndex }) => {
|
||||
return {
|
||||
zIndex: zIndex ?? 'var(--affine-z-index-popover)',
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { PopperProps as PopperUnstyledProps } from '@mui/base/Popper';
|
||||
import Grow from '@mui/material/Grow';
|
||||
import type { CSSProperties, PropsWithChildren } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { PopperArrow } from './popover-arrow';
|
||||
import { BasicStyledPopper } from './popper';
|
||||
import { PopperWrapper } from './styles';
|
||||
|
||||
export type PurePopperProps = {
|
||||
zIndex?: CSSProperties['zIndex'];
|
||||
|
||||
offset?: [number, number];
|
||||
|
||||
showArrow?: boolean;
|
||||
} & PopperUnstyledProps &
|
||||
PropsWithChildren;
|
||||
|
||||
export const PurePopper = (props: PurePopperProps) => {
|
||||
const {
|
||||
children,
|
||||
zIndex,
|
||||
offset,
|
||||
showArrow = false,
|
||||
modifiers = [],
|
||||
placement,
|
||||
...otherProps
|
||||
} = props;
|
||||
const [arrowRef, setArrowRef] = useState<HTMLElement | null>();
|
||||
|
||||
return (
|
||||
<BasicStyledPopper
|
||||
zIndex={zIndex}
|
||||
transition
|
||||
modifiers={[
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'arrow',
|
||||
enabled: showArrow,
|
||||
options: {
|
||||
element: arrowRef,
|
||||
},
|
||||
},
|
||||
...modifiers,
|
||||
]}
|
||||
placement={placement}
|
||||
{...otherProps}
|
||||
>
|
||||
{({ TransitionProps }) => (
|
||||
<Grow {...TransitionProps}>
|
||||
<PopperWrapper>
|
||||
{showArrow && (
|
||||
<PopperArrow placement={placement} ref={setArrowRef} />
|
||||
)}
|
||||
{children}
|
||||
</PopperWrapper>
|
||||
</Grow>
|
||||
)}
|
||||
</BasicStyledPopper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { styled } from '../../styles';
|
||||
|
||||
export const PopperWrapper = styled('div')(() => {
|
||||
return {
|
||||
position: 'relative',
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const scrollableContainerRoot = style({
|
||||
width: '100%',
|
||||
vars: {
|
||||
'--scrollbar-width': '8px',
|
||||
},
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
export const scrollTopBorder = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '16px',
|
||||
right: '16px',
|
||||
height: '1px',
|
||||
transition: 'opacity .3s .2s',
|
||||
opacity: 0,
|
||||
background: 'var(--affine-border-color)',
|
||||
selectors: {
|
||||
'&[data-has-scroll-top="true"]': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const scrollableViewport = style({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
globalStyle(`${scrollableViewport} > div`, {
|
||||
maxWidth: '100%',
|
||||
display: 'block !important',
|
||||
});
|
||||
|
||||
export const scrollableContainer = style({
|
||||
height: '100%',
|
||||
marginBottom: '4px',
|
||||
});
|
||||
|
||||
export const scrollbar = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
marginRight: '4px',
|
||||
width: 'var(--scrollbar-width)',
|
||||
height: '100%',
|
||||
opacity: 1,
|
||||
transition: 'width .15s',
|
||||
':hover': {
|
||||
background: 'var(--affine-background-secondary-color)',
|
||||
width: 'calc(var(--scrollbar-width) + 3px)',
|
||||
borderLeft: '1px solid var(--affine-border-color)',
|
||||
},
|
||||
selectors: {
|
||||
'&[data-state="hidden"]': {
|
||||
opacity: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const TableScrollbar = style({
|
||||
marginTop: '60px',
|
||||
height: 'calc(100% - 120px)',
|
||||
borderRadius: '4px',
|
||||
});
|
||||
|
||||
export const scrollbarThumb = style({
|
||||
position: 'relative',
|
||||
background: 'var(--affine-divider-color)',
|
||||
width: '50%',
|
||||
overflow: 'hidden',
|
||||
borderRadius: '4px',
|
||||
':hover': {
|
||||
background: 'var(--affine-icon-color)',
|
||||
},
|
||||
selectors: {
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minWidth: '44px',
|
||||
minHeight: '44px',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './scrollbar';
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||
import clsx from 'clsx';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
import { useHasScrollTop } from '../../components/app-sidebar/sidebar-containers/use-has-scroll-top';
|
||||
import * as styles from './index.css';
|
||||
|
||||
export type ScrollableContainerProps = {
|
||||
showScrollTopBorder?: boolean;
|
||||
inTableView?: boolean;
|
||||
className?: string;
|
||||
viewPortClassName?: string;
|
||||
styles?: React.CSSProperties;
|
||||
scrollBarClassName?: string;
|
||||
};
|
||||
|
||||
export const ScrollableContainer = ({
|
||||
children,
|
||||
showScrollTopBorder = false,
|
||||
inTableView = false,
|
||||
className,
|
||||
styles: _styles,
|
||||
viewPortClassName,
|
||||
scrollBarClassName,
|
||||
}: PropsWithChildren<ScrollableContainerProps>) => {
|
||||
const [hasScrollTop, ref] = useHasScrollTop();
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
style={_styles}
|
||||
className={clsx(styles.scrollableContainerRoot, className)}
|
||||
>
|
||||
<div
|
||||
data-has-scroll-top={hasScrollTop}
|
||||
className={clsx({ [styles.scrollTopBorder]: showScrollTopBorder })}
|
||||
/>
|
||||
<ScrollArea.Viewport
|
||||
className={clsx([styles.scrollableViewport, viewPortClassName])}
|
||||
ref={ref}
|
||||
>
|
||||
<div className={styles.scrollableContainer}>{children}</div>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation="vertical"
|
||||
className={clsx(styles.scrollbar, scrollBarClassName, {
|
||||
[styles.TableScrollbar]: inTableView,
|
||||
})}
|
||||
>
|
||||
<ScrollArea.Thumb className={styles.scrollbarThumb} />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { PopperPlacementType } from '@mui/material';
|
||||
|
||||
import { styled } from '../../styles';
|
||||
|
||||
export type PopperDirection =
|
||||
| 'none'
|
||||
| 'left-top'
|
||||
| 'left-bottom'
|
||||
| 'right-top'
|
||||
| 'right-bottom';
|
||||
|
||||
const getBorderRadius = (direction: PopperDirection, radius = '0') => {
|
||||
const map: Record<PopperDirection, string> = {
|
||||
none: `${radius}`,
|
||||
'left-top': `0 ${radius} ${radius} ${radius}`,
|
||||
'left-bottom': `${radius} ${radius} ${radius} 0`,
|
||||
'right-top': `${radius} 0 ${radius} ${radius}`,
|
||||
'right-bottom': `${radius} ${radius} 0 ${radius}`,
|
||||
};
|
||||
return map[direction];
|
||||
};
|
||||
|
||||
export const placementToContainerDirection: Record<
|
||||
PopperPlacementType,
|
||||
PopperDirection
|
||||
> = {
|
||||
top: 'none',
|
||||
'top-start': 'left-bottom',
|
||||
'top-end': 'right-bottom',
|
||||
right: 'none',
|
||||
'right-start': 'left-top',
|
||||
'right-end': 'left-bottom',
|
||||
bottom: 'none',
|
||||
'bottom-start': 'none',
|
||||
'bottom-end': 'none',
|
||||
left: 'none',
|
||||
'left-start': 'right-top',
|
||||
'left-end': 'right-bottom',
|
||||
auto: 'none',
|
||||
'auto-start': 'none',
|
||||
'auto-end': 'none',
|
||||
};
|
||||
|
||||
export const StyledPopperContainer = styled('div')<{
|
||||
placement?: PopperPlacementType;
|
||||
}>(({ placement = 'top' }) => {
|
||||
const direction = placementToContainerDirection[placement];
|
||||
const borderRadius = getBorderRadius(
|
||||
direction,
|
||||
'var(--affine-popover-radius)'
|
||||
);
|
||||
return {
|
||||
borderRadius,
|
||||
};
|
||||
});
|
||||
|
||||
export default StyledPopperContainer;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const labelStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
export const inputStyle = style({
|
||||
opacity: 0,
|
||||
position: 'absolute',
|
||||
});
|
||||
export const switchStyle = style({
|
||||
position: 'relative',
|
||||
width: '46px',
|
||||
height: '26px',
|
||||
background: 'var(--affine-icon-color)',
|
||||
borderRadius: '37px',
|
||||
transition: '200ms all',
|
||||
border: '1px solid var(--affine-black-10)',
|
||||
boxShadow: 'var(--affine-toggle-circle-shadow)',
|
||||
selectors: {
|
||||
'&:before': {
|
||||
transition: 'all .2s cubic-bezier(0.27, 0.2, 0.25, 1.51)',
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
top: '50%',
|
||||
border: '1px solid var(--affine-black-10)',
|
||||
background: 'var(--affine-white)',
|
||||
transform: 'translate(1px, -50%)',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const switchCheckedStyle = style({
|
||||
background: 'var(--affine-primary-color)',
|
||||
selectors: {
|
||||
'&:before': {
|
||||
background: 'var(--affine-toggle-circle-background-color)',
|
||||
transform: 'translate(21px,-50%)',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './switch';
|
||||
@@ -0,0 +1,53 @@
|
||||
// components/switch.tsx
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
|
||||
type SwitchProps = Omit<HTMLAttributes<HTMLLabelElement>, 'onChange'> & {
|
||||
checked?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const Switch = ({
|
||||
checked: checkedProp = false,
|
||||
onChange: onChangeProp,
|
||||
children,
|
||||
...otherProps
|
||||
}: SwitchProps) => {
|
||||
const [checkedState, setCheckedState] = useState(checkedProp);
|
||||
|
||||
const checked = onChangeProp ? checkedProp : checkedState;
|
||||
const onChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newChecked = event.target.checked;
|
||||
if (onChangeProp) onChangeProp(newChecked);
|
||||
else setCheckedState(newChecked);
|
||||
},
|
||||
[onChangeProp]
|
||||
);
|
||||
|
||||
return (
|
||||
<label className={clsx(styles.labelStyle)} {...otherProps}>
|
||||
{children}
|
||||
<input
|
||||
className={clsx(styles.inputStyle)}
|
||||
type="checkbox"
|
||||
value={checked ? 'on' : 'off'}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<span
|
||||
className={clsx(styles.switchStyle, {
|
||||
[styles.switchCheckedStyle]: checked,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
// import Table from '@mui/material/Table';
|
||||
// import TableBody from '@mui/material/TableBody';
|
||||
// import TableCell from '@mui/material/TableCell';
|
||||
// import TableHead from '@mui/material/TableHead';
|
||||
// import TableRow from '@mui/material/TableRow';
|
||||
//
|
||||
|
||||
export * from './interface';
|
||||
export * from './table';
|
||||
export * from './table-body';
|
||||
export * from './table-cell';
|
||||
export * from './table-head';
|
||||
export * from './table-row';
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { CSSProperties, HTMLAttributes, PropsWithChildren } from 'react';
|
||||
|
||||
export type TableCellProps = {
|
||||
align?: 'left' | 'right' | 'center';
|
||||
ellipsis?: boolean;
|
||||
proportion?: number;
|
||||
active?: boolean;
|
||||
style?: CSSProperties;
|
||||
} & PropsWithChildren &
|
||||
HTMLAttributes<HTMLTableCellElement>;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { styled, textEllipsis } from '../../styles';
|
||||
import type { TableCellProps } from './interface';
|
||||
|
||||
export const StyledTable = styled('table')<{ showBorder?: boolean }>(({
|
||||
showBorder,
|
||||
}) => {
|
||||
return {
|
||||
fontSize: 'var(--affine-font-base)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
tableLayout: 'fixed',
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
borderSpacing: '0',
|
||||
|
||||
...(typeof showBorder === 'boolean'
|
||||
? {
|
||||
thead: {
|
||||
'::after': {
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
content: '""',
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
left: 0,
|
||||
background: 'var(--affine-border-color)',
|
||||
transition: 'opacity .15s',
|
||||
opacity: showBorder ? 1 : 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledTableBody = styled('tbody')(() => {
|
||||
return {
|
||||
fontWeight: 400,
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledTableCell = styled('td')<
|
||||
Pick<
|
||||
TableCellProps,
|
||||
'ellipsis' | 'align' | 'proportion' | 'active' | 'onClick'
|
||||
>
|
||||
>(({
|
||||
align = 'left',
|
||||
ellipsis = false,
|
||||
proportion,
|
||||
active = false,
|
||||
onClick,
|
||||
}) => {
|
||||
const width = proportion ? `${proportion * 100}%` : 'auto';
|
||||
return {
|
||||
width,
|
||||
height: '52px',
|
||||
paddingLeft: '16px',
|
||||
boxSizing: 'border-box',
|
||||
textAlign: align,
|
||||
verticalAlign: 'middle',
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
...(active ? { color: 'var(--affine-text-primary-color)' } : {}),
|
||||
...(ellipsis ? textEllipsis(1) : {}),
|
||||
...(onClick ? { cursor: 'pointer' } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledTableHead = styled('thead')(() => {
|
||||
return {
|
||||
fontWeight: 500,
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledTHeadRow = styled('tr')(() => {
|
||||
return {
|
||||
td: {
|
||||
whiteSpace: 'nowrap',
|
||||
// How to set tbody height with overflow scroll
|
||||
// see https://stackoverflow.com/questions/23989463/how-to-set-tbody-height-with-overflow-scroll
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledTBodyRow = styled('tr')(() => {
|
||||
return {
|
||||
td: {
|
||||
transition: 'background .15s',
|
||||
},
|
||||
// Add border radius to table row
|
||||
// see https://stackoverflow.com/questions/4094126/how-to-add-border-radius-on-table-row
|
||||
'td:first-of-type': {
|
||||
borderTopLeftRadius: '10px',
|
||||
borderBottomLeftRadius: '10px',
|
||||
},
|
||||
'td:last-of-type': {
|
||||
borderTopRightRadius: '10px',
|
||||
borderBottomRightRadius: '10px',
|
||||
},
|
||||
|
||||
':hover': {
|
||||
td: {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { StyledTableBody } from './styles';
|
||||
export const TableBody = StyledTableBody;
|
||||
|
||||
export default TableBody;
|
||||
@@ -0,0 +1,4 @@
|
||||
import { StyledTableCell } from './styles';
|
||||
export const TableCell = StyledTableCell;
|
||||
|
||||
export default TableCell;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { StyledTableHead } from './styles';
|
||||
|
||||
export const TableHead = StyledTableHead;
|
||||
|
||||
export default TableHead;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { StyledTBodyRow, StyledTHeadRow } from './styles';
|
||||
export const TableHeadRow = StyledTHeadRow;
|
||||
export const TableBodyRow = StyledTBodyRow;
|
||||
|
||||
export default TableHeadRow;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { StyledTable } from './styles';
|
||||
|
||||
export const Table = StyledTable;
|
||||
|
||||
export default Table;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './toast';
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright: https://github.com/toeverything/blocksuite/commit/8032ef3ab97aefce01664b36502fc392c5db8b78#diff-bf5b41be21936f9165a8400c7f20e24d3dbc49644ba57b9258e0943f0dc1c464
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import type { TemplateResult } from 'lit';
|
||||
import { css, html } from 'lit';
|
||||
|
||||
const logger = new DebugLogger('toast');
|
||||
|
||||
export const sleep = (ms = 0) =>
|
||||
new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
let ToastContainer: HTMLDivElement | null = null;
|
||||
|
||||
/**
|
||||
* DO NOT USE FOR USER INPUT
|
||||
* See https://stackoverflow.com/questions/494143/creating-a-new-dom-element-from-an-html-string-using-built-in-dom-methods-or-pro/35385518#35385518
|
||||
*/
|
||||
const htmlToElement = <T extends ChildNode>(html: string | TemplateResult) => {
|
||||
const template = document.createElement('template');
|
||||
if (typeof html === 'string') {
|
||||
html = html.trim(); // Never return a text node of whitespace as the result
|
||||
template.innerHTML = html;
|
||||
} else {
|
||||
const { strings, values } = html;
|
||||
const v = [...values, '']; // + last empty part
|
||||
template.innerHTML = strings.reduce((acc, cur, i) => acc + cur + v[i], '');
|
||||
}
|
||||
return template.content.firstChild as T;
|
||||
};
|
||||
|
||||
const createToastContainer = (portal?: HTMLElement) => {
|
||||
portal = portal || document.body;
|
||||
const styles = css`
|
||||
position: absolute;
|
||||
z-index: 9999;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 78px;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
`;
|
||||
const template = html`<div
|
||||
style="${styles}"
|
||||
data-testid="affine-toast-container"
|
||||
></div>`;
|
||||
const element = htmlToElement<HTMLDivElement>(template);
|
||||
portal.appendChild(element);
|
||||
return element;
|
||||
};
|
||||
|
||||
export type ToastOptions = {
|
||||
duration?: number;
|
||||
portal?: HTMLElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* @example
|
||||
* ```ts
|
||||
* toast('Hello World');
|
||||
* ```
|
||||
*/
|
||||
export const toast = (
|
||||
message: string,
|
||||
{ duration = 2500, portal }: ToastOptions = {
|
||||
duration: 2500,
|
||||
}
|
||||
) => {
|
||||
if (!ToastContainer || (portal && !portal.contains(ToastContainer))) {
|
||||
ToastContainer = createToastContainer(portal);
|
||||
}
|
||||
|
||||
const styles = css`
|
||||
max-width: 480px;
|
||||
text-align: center;
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-sm);
|
||||
padding: 6px 12px;
|
||||
margin: 10px 0 0 0;
|
||||
color: var(--affine-white);
|
||||
background: var(--affine-tooltip);
|
||||
box-shadow: var(--affine-float-button-shadow);
|
||||
border-radius: 10px;
|
||||
transition: all 230ms cubic-bezier(0.21, 1.02, 0.73, 1);
|
||||
opacity: 0;
|
||||
`;
|
||||
|
||||
const template = html`<div
|
||||
style="${styles}"
|
||||
data-testid="affine-toast"
|
||||
></div>`;
|
||||
const element = htmlToElement<HTMLDivElement>(template);
|
||||
// message is not trusted
|
||||
element.textContent = message;
|
||||
ToastContainer.appendChild(element);
|
||||
|
||||
logger.debug(`toast with message: "${message}"`);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('affine-toast:emit', { detail: message })
|
||||
);
|
||||
|
||||
const fadeIn = [
|
||||
{
|
||||
opacity: 0,
|
||||
},
|
||||
{ opacity: 1 },
|
||||
];
|
||||
|
||||
const options = {
|
||||
duration: 230,
|
||||
easing: 'cubic-bezier(0.21, 1.02, 0.73, 1)',
|
||||
fill: 'forwards' as const,
|
||||
} satisfies KeyframeAnimationOptions;
|
||||
|
||||
element.animate(fadeIn, options);
|
||||
|
||||
setTimeout(() => {
|
||||
const animation = element.animate(
|
||||
// fade out
|
||||
fadeIn.reverse(),
|
||||
options
|
||||
);
|
||||
animation.finished
|
||||
.then(() => {
|
||||
element.style.maxHeight = '0';
|
||||
element.style.margin = '0';
|
||||
element.style.padding = '0';
|
||||
// wait for transition
|
||||
// ToastContainer = null;
|
||||
element.addEventListener('transitionend', () => {
|
||||
element.remove();
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, duration);
|
||||
return element;
|
||||
};
|
||||
|
||||
export default toast;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { TreeNodeProps } from '../types';
|
||||
export const useCollapsed = ({
|
||||
initialCollapsedIds = [],
|
||||
disableCollapse = false,
|
||||
}: {
|
||||
disableCollapse?: boolean;
|
||||
initialCollapsedIds?: string[];
|
||||
}) => {
|
||||
// TODO: should record collapsedIds in localStorage
|
||||
const [collapsedIds, setCollapsedIds] =
|
||||
useState<string[]>(initialCollapsedIds);
|
||||
|
||||
const setCollapsed: TreeNodeProps['setCollapsed'] = (id, collapsed) => {
|
||||
if (disableCollapse) {
|
||||
return;
|
||||
}
|
||||
if (collapsed) {
|
||||
setCollapsedIds(ids => [...ids, id]);
|
||||
} else {
|
||||
setCollapsedIds(ids => ids.filter(i => i !== id));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
collapsedIds,
|
||||
setCollapsed,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCollapsed;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { TreeViewProps } from '../types';
|
||||
import { flattenIds } from '../utils';
|
||||
export const useSelectWithKeyboard = <RenderProps>({
|
||||
data,
|
||||
enableKeyboardSelection,
|
||||
onSelect,
|
||||
}: Pick<
|
||||
TreeViewProps<RenderProps>,
|
||||
'data' | 'enableKeyboardSelection' | 'onSelect'
|
||||
>) => {
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
// TODO: should record collapsedIds in localStorage
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableKeyboardSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const flattenedIds = flattenIds<RenderProps>(data);
|
||||
|
||||
const handleDirectionKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') {
|
||||
return;
|
||||
}
|
||||
if (selectedId === undefined) {
|
||||
setSelectedId(flattenedIds[0]);
|
||||
return;
|
||||
}
|
||||
let selectedIndex = flattenedIds.indexOf(selectedId);
|
||||
if (e.key === 'ArrowDown') {
|
||||
selectedIndex < flattenedIds.length - 1 && selectedIndex++;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
selectedIndex > 0 && selectedIndex--;
|
||||
}
|
||||
|
||||
setSelectedId(flattenedIds[selectedIndex]);
|
||||
};
|
||||
|
||||
const handleEnterKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
selectedId && onSelect?.(selectedId);
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleDirectionKeyDown);
|
||||
document.addEventListener('keydown', handleEnterKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleDirectionKeyDown);
|
||||
document.removeEventListener('keydown', handleEnterKeyDown);
|
||||
};
|
||||
}, [data, enableKeyboardSelection, onSelect, selectedId]);
|
||||
|
||||
return {
|
||||
selectedId,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSelectWithKeyboard;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './tree-node';
|
||||
export * from './tree-view';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,44 @@
|
||||
import MuiCollapse from '@mui/material/Collapse';
|
||||
import { lightTheme } from '@toeverything/theme';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { alpha, styled } from '../../styles';
|
||||
|
||||
export const StyledCollapse = styled(MuiCollapse)<{
|
||||
indent?: CSSProperties['paddingLeft'];
|
||||
}>(({ indent = 12 }) => {
|
||||
return {
|
||||
paddingLeft: indent,
|
||||
};
|
||||
});
|
||||
export const StyledTreeNodeWrapper = styled('div')(() => {
|
||||
return {
|
||||
position: 'relative',
|
||||
};
|
||||
});
|
||||
export const StyledTreeNodeContainer = styled('div')<{ isDragging?: boolean }>(
|
||||
({ isDragging = false }) => {
|
||||
return {
|
||||
background: isDragging ? 'var(--affine-hover-color)' : '',
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const StyledNodeLine = styled('div')<{
|
||||
isOver: boolean;
|
||||
isTop?: boolean;
|
||||
}>(({ isOver, isTop = false }) => {
|
||||
return {
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
...(isTop ? { top: '-1px' } : { bottom: '-1px' }),
|
||||
width: '100%',
|
||||
paddingTop: '2x',
|
||||
borderTop: '2px solid',
|
||||
borderColor: isOver ? 'var(--affine-primary-color)' : 'transparent',
|
||||
boxShadow: isOver
|
||||
? `0px 0px 8px ${alpha(lightTheme.primaryColor, 0.35)}`
|
||||
: 'none',
|
||||
zIndex: 1,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
|
||||
import { StyledNodeLine } from './styles';
|
||||
import type { NodeLIneProps, TreeNodeItemProps } from './types';
|
||||
|
||||
export const NodeLine = <RenderProps,>({
|
||||
node,
|
||||
allowDrop = true,
|
||||
isTop = false,
|
||||
}: NodeLIneProps<RenderProps>) => {
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: `${node.id}-${isTop ? 'top' : 'bottom'}-line`,
|
||||
disabled: !allowDrop,
|
||||
data: {
|
||||
node,
|
||||
position: {
|
||||
topLine: isTop,
|
||||
bottomLine: !isTop,
|
||||
internal: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledNodeLine
|
||||
ref={setNodeRef}
|
||||
isOver={isOver && allowDrop}
|
||||
isTop={isTop}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export const TreeNodeItemWithDnd = <RenderProps,>({
|
||||
node,
|
||||
allowDrop,
|
||||
setCollapsed,
|
||||
...otherProps
|
||||
}: TreeNodeItemProps<RenderProps>) => {
|
||||
const { onAdd, onDelete } = otherProps;
|
||||
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: node.id,
|
||||
disabled: !allowDrop,
|
||||
data: {
|
||||
node,
|
||||
position: {
|
||||
topLine: false,
|
||||
bottomLine: false,
|
||||
internal: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef}>
|
||||
<TreeNodeItem
|
||||
onAdd={onAdd}
|
||||
onDelete={onDelete}
|
||||
node={node}
|
||||
allowDrop={allowDrop}
|
||||
setCollapsed={setCollapsed}
|
||||
isOver={isOver}
|
||||
{...otherProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TreeNodeItem = <RenderProps,>({
|
||||
node,
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
selectedId,
|
||||
isOver = false,
|
||||
onAdd,
|
||||
onDelete,
|
||||
disableCollapse,
|
||||
allowDrop = true,
|
||||
}: TreeNodeItemProps<RenderProps>) => {
|
||||
return (
|
||||
<>
|
||||
{node.render?.(node, {
|
||||
isOver: isOver && allowDrop,
|
||||
onAdd: () => onAdd?.(node.id),
|
||||
onDelete: () => onDelete?.(node.id),
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
isSelected: selectedId === node.id,
|
||||
disableCollapse,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
StyledCollapse,
|
||||
StyledTreeNodeContainer,
|
||||
StyledTreeNodeWrapper,
|
||||
} from './styles';
|
||||
import { NodeLine, TreeNodeItem, TreeNodeItemWithDnd } from './tree-node-inner';
|
||||
import type { TreeNodeProps } from './types';
|
||||
export const TreeNodeWithDnd = <RenderProps,>(
|
||||
props: TreeNodeProps<RenderProps>
|
||||
) => {
|
||||
const { draggingId, node, allowDrop } = props;
|
||||
const { attributes, listeners, setNodeRef } = useDraggable({
|
||||
id: props.node.id,
|
||||
});
|
||||
const isDragging = useMemo(
|
||||
() => draggingId === node.id,
|
||||
[draggingId, node.id]
|
||||
);
|
||||
return (
|
||||
<StyledTreeNodeContainer
|
||||
ref={setNodeRef}
|
||||
isDragging={isDragging}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<TreeNode
|
||||
{...props}
|
||||
allowDrop={allowDrop === false ? allowDrop : !isDragging}
|
||||
/>
|
||||
</StyledTreeNodeContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export const TreeNode = <RenderProps,>({
|
||||
node,
|
||||
index,
|
||||
allowDrop = true,
|
||||
...otherProps
|
||||
}: TreeNodeProps<RenderProps>) => {
|
||||
const { indent, enableDnd, collapsedIds } = otherProps;
|
||||
const collapsed = collapsedIds.includes(node.id);
|
||||
const { renderTopLine = true, renderBottomLine = true } = node;
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledTreeNodeWrapper>
|
||||
{enableDnd && renderTopLine && index === 0 && (
|
||||
<NodeLine
|
||||
node={node}
|
||||
{...otherProps}
|
||||
allowDrop={allowDrop}
|
||||
isTop={true}
|
||||
/>
|
||||
)}
|
||||
{enableDnd ? (
|
||||
<TreeNodeItemWithDnd
|
||||
node={node}
|
||||
index={index}
|
||||
allowDrop={allowDrop}
|
||||
collapsed={collapsed}
|
||||
{...otherProps}
|
||||
/>
|
||||
) : (
|
||||
<TreeNodeItem
|
||||
node={node}
|
||||
index={index}
|
||||
allowDrop={allowDrop}
|
||||
collapsed={collapsed}
|
||||
{...otherProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{enableDnd &&
|
||||
renderBottomLine &&
|
||||
(!node.children?.length || collapsed) && (
|
||||
<NodeLine node={node} {...otherProps} allowDrop={allowDrop} />
|
||||
)}
|
||||
</StyledTreeNodeWrapper>
|
||||
<StyledCollapse in={!collapsed} indent={indent}>
|
||||
{node.children &&
|
||||
node.children.map((childNode, index) =>
|
||||
enableDnd ? (
|
||||
<TreeNodeWithDnd
|
||||
key={childNode.id}
|
||||
node={childNode}
|
||||
index={index}
|
||||
{...otherProps}
|
||||
allowDrop={allowDrop}
|
||||
/>
|
||||
) : (
|
||||
<TreeNode
|
||||
key={childNode.id}
|
||||
node={childNode}
|
||||
index={index}
|
||||
allowDrop={false}
|
||||
{...otherProps}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</StyledCollapse>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import useCollapsed from './hooks/use-collapsed';
|
||||
import useSelectWithKeyboard from './hooks/use-select-with-keyboard';
|
||||
import { TreeNode, TreeNodeWithDnd } from './tree-node';
|
||||
import type { Node, TreeViewProps } from './types';
|
||||
import { findNode } from './utils';
|
||||
export const TreeView = <RenderProps,>({
|
||||
data,
|
||||
enableKeyboardSelection,
|
||||
onSelect,
|
||||
enableDnd = true,
|
||||
disableCollapse,
|
||||
onDrop,
|
||||
...otherProps
|
||||
}: TreeViewProps<RenderProps>) => {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
const { selectedId } = useSelectWithKeyboard({
|
||||
data,
|
||||
onSelect,
|
||||
enableKeyboardSelection,
|
||||
});
|
||||
|
||||
const { collapsedIds, setCollapsed } = useCollapsed({ disableCollapse });
|
||||
|
||||
const [draggingId, setDraggingId] = useState<string>();
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(e: DragEndEvent) => {
|
||||
const { active, over } = e;
|
||||
const position = over?.data.current?.position;
|
||||
const dropId = over?.data.current?.node.id;
|
||||
setDraggingId(undefined);
|
||||
if (!over || !active || !position) {
|
||||
return;
|
||||
}
|
||||
|
||||
onDrop?.(active.id as string, dropId, position);
|
||||
},
|
||||
[onDrop]
|
||||
);
|
||||
const onDragMove = useCallback((e: DragEndEvent) => {
|
||||
setDraggingId(e.active.id as string);
|
||||
}, []);
|
||||
if (enableDnd) {
|
||||
const treeNodes = data.map((node, index) => (
|
||||
<TreeNodeWithDnd
|
||||
key={node.id}
|
||||
index={index}
|
||||
collapsedIds={collapsedIds}
|
||||
setCollapsed={setCollapsed}
|
||||
node={node}
|
||||
selectedId={selectedId}
|
||||
enableDnd={enableDnd}
|
||||
disableCollapse={disableCollapse}
|
||||
draggingId={draggingId}
|
||||
{...otherProps}
|
||||
/>
|
||||
));
|
||||
const draggingNode = (function () {
|
||||
let draggingNode: Node<RenderProps> | undefined;
|
||||
if (draggingId) {
|
||||
draggingNode = findNode(draggingId, data);
|
||||
}
|
||||
if (draggingNode) {
|
||||
return (
|
||||
<TreeNode
|
||||
node={draggingNode}
|
||||
index={0}
|
||||
allowDrop={false}
|
||||
collapsedIds={collapsedIds}
|
||||
setCollapsed={() => {}}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
{treeNodes}
|
||||
<DragOverlay>{draggingNode}</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{data.map((node, index) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
index={index}
|
||||
collapsedIds={collapsedIds}
|
||||
setCollapsed={setCollapsed}
|
||||
node={node}
|
||||
selectedId={selectedId}
|
||||
enableDnd={enableDnd}
|
||||
disableCollapse={disableCollapse}
|
||||
{...otherProps}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TreeView;
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
export type DropPosition = {
|
||||
topLine: boolean;
|
||||
bottomLine: boolean;
|
||||
internal: boolean;
|
||||
};
|
||||
export type OnDrop = (
|
||||
dragId: string,
|
||||
dropId: string,
|
||||
position: DropPosition
|
||||
) => void;
|
||||
|
||||
export type Node<RenderProps = unknown> = {
|
||||
id: string;
|
||||
children?: Node<RenderProps>[];
|
||||
render: (
|
||||
node: Node<RenderProps>,
|
||||
eventsAndStatus: {
|
||||
isOver: boolean;
|
||||
onAdd: () => void;
|
||||
onDelete: () => void;
|
||||
collapsed: boolean;
|
||||
setCollapsed: (id: string, collapsed: boolean) => void;
|
||||
isSelected: boolean;
|
||||
disableCollapse?: ReactNode;
|
||||
},
|
||||
renderProps?: RenderProps
|
||||
) => ReactNode;
|
||||
renderTopLine?: boolean;
|
||||
renderBottomLine?: boolean;
|
||||
};
|
||||
|
||||
type CommonProps = {
|
||||
enableDnd?: boolean;
|
||||
enableKeyboardSelection?: boolean;
|
||||
indent?: CSSProperties['paddingLeft'];
|
||||
onAdd?: (parentId: string) => void;
|
||||
onDelete?: (deleteId: string) => void;
|
||||
onDrop?: OnDrop;
|
||||
// Only trigger when the enableKeyboardSelection is true
|
||||
onSelect?: (id: string) => void;
|
||||
disableCollapse?: ReactNode;
|
||||
};
|
||||
|
||||
export type TreeNodeProps<RenderProps = unknown> = {
|
||||
node: Node<RenderProps>;
|
||||
index: number;
|
||||
collapsedIds: string[];
|
||||
setCollapsed: (id: string, collapsed: boolean) => void;
|
||||
allowDrop?: boolean;
|
||||
selectedId?: string;
|
||||
draggingId?: string;
|
||||
} & CommonProps;
|
||||
|
||||
export type TreeNodeItemProps<RenderProps = unknown> = {
|
||||
collapsed: boolean;
|
||||
setCollapsed: (id: string, collapsed: boolean) => void;
|
||||
|
||||
isOver?: boolean;
|
||||
} & TreeNodeProps<RenderProps>;
|
||||
|
||||
export type TreeViewProps<RenderProps = unknown> = {
|
||||
data: Node<RenderProps>[];
|
||||
initialCollapsedIds?: string[];
|
||||
disableCollapse?: boolean;
|
||||
} & CommonProps;
|
||||
|
||||
export type NodeLIneProps<RenderProps = unknown> = {
|
||||
allowDrop: boolean;
|
||||
isTop?: boolean;
|
||||
} & Pick<TreeNodeProps<RenderProps>, 'node'>;
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Node } from './types';
|
||||
|
||||
export function flattenIds<RenderProps>(arr: Node<RenderProps>[]): string[] {
|
||||
const result: string[] = [];
|
||||
|
||||
function flatten(arr: Node<RenderProps>[]) {
|
||||
for (let i = 0, len = arr.length; i < len; i++) {
|
||||
const item = arr[i];
|
||||
result.push(item.id);
|
||||
if (Array.isArray(item.children)) {
|
||||
flatten(item.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flatten(arr);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function findNode<RenderProps>(
|
||||
id: string,
|
||||
nodes: Node<RenderProps>[]
|
||||
): Node<RenderProps> | undefined {
|
||||
for (let i = 0, len = nodes.length; i < len; i++) {
|
||||
const node = nodes[i];
|
||||
if (node.id === id) {
|
||||
return node;
|
||||
}
|
||||
if (node.children) {
|
||||
const result = findNode(id, node.children);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user