mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 05:14:54 +00:00
refactor(infra): directory structure (#4615)
This commit is contained in:
14
packages/frontend/component/src/ui/breadcrumbs/index.ts
Normal file
14
packages/frontend/component/src/ui/breadcrumbs/index.ts
Normal file
@@ -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;
|
||||
36
packages/frontend/component/src/ui/button/dropdown.tsx
Normal file
36
packages/frontend/component/src/ui/button/dropdown.tsx
Normal file
@@ -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';
|
||||
2
packages/frontend/component/src/ui/button/index.ts
Normal file
2
packages/frontend/component/src/ui/button/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './dropdown';
|
||||
export * from './radio';
|
||||
26
packages/frontend/component/src/ui/button/interface.ts
Normal file
26
packages/frontend/component/src/ui/button/interface.ts
Normal file
@@ -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;
|
||||
};
|
||||
60
packages/frontend/component/src/ui/button/loading.tsx
Normal file
60
packages/frontend/component/src/ui/button/loading.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
47
packages/frontend/component/src/ui/button/radio.tsx
Normal file
47
packages/frontend/component/src/ui/button/radio.tsx
Normal file
@@ -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';
|
||||
363
packages/frontend/component/src/ui/button/style.css.ts
Normal file
363
packages/frontend/component/src/ui/button/style.css.ts
Normal file
@@ -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)',
|
||||
},
|
||||
},
|
||||
});
|
||||
108
packages/frontend/component/src/ui/button/styles.css.ts
Normal file
108
packages/frontend/component/src/ui/button/styles.css.ts
Normal file
@@ -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',
|
||||
});
|
||||
93
packages/frontend/component/src/ui/button/styles.ts
Normal file
93
packages/frontend/component/src/ui/button/styles.ts
Normal file
@@ -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 ?? {}),
|
||||
// },
|
||||
};
|
||||
});
|
||||
124
packages/frontend/component/src/ui/button/utils.ts
Normal file
124
packages/frontend/component/src/ui/button/utils.ts
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
33
packages/frontend/component/src/ui/empty/empty-svg.tsx
Normal file
33
packages/frontend/component/src/ui/empty/empty-svg.tsx
Normal file
@@ -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>
|
||||
);
|
||||
});
|
||||
43
packages/frontend/component/src/ui/empty/empty.tsx
Normal file
43
packages/frontend/component/src/ui/empty/empty.tsx
Normal file
@@ -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;
|
||||
1
packages/frontend/component/src/ui/empty/index.ts
Normal file
1
packages/frontend/component/src/ui/empty/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './empty';
|
||||
19
packages/frontend/component/src/ui/empty/style.ts
Normal file
19
packages/frontend/component/src/ui/empty/style.ts
Normal file
@@ -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',
|
||||
},
|
||||
};
|
||||
});
|
||||
39
packages/frontend/component/src/ui/input/index.stories.tsx
Normal file
39
packages/frontend/component/src/ui/input/index.stories.tsx
Normal file
@@ -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" />;
|
||||
};
|
||||
3
packages/frontend/component/src/ui/input/index.ts
Normal file
3
packages/frontend/component/src/ui/input/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './input';
|
||||
import { Input } from './input';
|
||||
export default Input;
|
||||
123
packages/frontend/component/src/ui/input/input.tsx
Normal file
123
packages/frontend/component/src/ui/input/input.tsx
Normal file
@@ -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>
|
||||
);
|
||||
});
|
||||
85
packages/frontend/component/src/ui/input/style.css.ts
Normal file
85
packages/frontend/component/src/ui/input/style.css.ts
Normal file
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
56
packages/frontend/component/src/ui/layout/content.tsx
Normal file
56
packages/frontend/component/src/ui/layout/content.tsx
Normal file
@@ -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;
|
||||
2
packages/frontend/component/src/ui/layout/index.ts
Normal file
2
packages/frontend/component/src/ui/layout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './content';
|
||||
export * from './wrapper';
|
||||
123
packages/frontend/component/src/ui/layout/wrapper.tsx
Normal file
123
packages/frontend/component/src/ui/layout/wrapper.tsx
Normal file
@@ -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;
|
||||
1
packages/frontend/component/src/ui/loading/index.ts
Normal file
1
packages/frontend/component/src/ui/loading/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './loading';
|
||||
30
packages/frontend/component/src/ui/loading/loading.tsx
Normal file
30
packages/frontend/component/src/ui/loading/loading.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
17
packages/frontend/component/src/ui/loading/styles.css.ts
Normal file
17
packages/frontend/component/src/ui/loading/styles.css.ts
Normal file
@@ -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`,
|
||||
});
|
||||
6
packages/frontend/component/src/ui/menu/index.ts
Normal file
6
packages/frontend/component/src/ui/menu/index.ts
Normal file
@@ -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';
|
||||
44
packages/frontend/component/src/ui/menu/menu-item.tsx
Normal file
44
packages/frontend/component/src/ui/menu/menu-item.tsx
Normal file
@@ -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;
|
||||
24
packages/frontend/component/src/ui/menu/pure-menu.tsx
Normal file
24
packages/frontend/component/src/ui/menu/pure-menu.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
115
packages/frontend/component/src/ui/menu/styles.ts
Normal file
115
packages/frontend/component/src/ui/menu/styles.ts
Normal file
@@ -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)',
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
19
packages/frontend/component/src/ui/mui.ts
Normal file
19
packages/frontend/component/src/ui/mui.ts
Normal file
@@ -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,
|
||||
};
|
||||
3
packages/frontend/component/src/ui/popper/index.ts
Normal file
3
packages/frontend/component/src/ui/popper/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './interface';
|
||||
export * from './popper';
|
||||
export * from './pure-popper';
|
||||
64
packages/frontend/component/src/ui/popper/interface.ts
Normal file
64
packages/frontend/component/src/ui/popper/interface.ts
Normal file
@@ -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'>;
|
||||
97
packages/frontend/component/src/ui/popper/popover-arrow.tsx
Normal file
97
packages/frontend/component/src/ui/popper/popover-arrow.tsx
Normal file
@@ -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)'),
|
||||
};
|
||||
});
|
||||
302
packages/frontend/component/src/ui/popper/popper.tsx
Normal file
302
packages/frontend/component/src/ui/popper/popper.tsx
Normal file
@@ -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)',
|
||||
};
|
||||
});
|
||||
66
packages/frontend/component/src/ui/popper/pure-popper.tsx
Normal file
66
packages/frontend/component/src/ui/popper/pure-popper.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
7
packages/frontend/component/src/ui/popper/styles.ts
Normal file
7
packages/frontend/component/src/ui/popper/styles.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { styled } from '../../styles';
|
||||
|
||||
export const PopperWrapper = styled('div')(() => {
|
||||
return {
|
||||
position: 'relative',
|
||||
};
|
||||
});
|
||||
91
packages/frontend/component/src/ui/scrollbar/index.css.ts
Normal file
91
packages/frontend/component/src/ui/scrollbar/index.css.ts
Normal file
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
1
packages/frontend/component/src/ui/scrollbar/index.ts
Normal file
1
packages/frontend/component/src/ui/scrollbar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './scrollbar';
|
||||
52
packages/frontend/component/src/ui/scrollbar/scrollbar.tsx
Normal file
52
packages/frontend/component/src/ui/scrollbar/scrollbar.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
57
packages/frontend/component/src/ui/shared/container.tsx
Normal file
57
packages/frontend/component/src/ui/shared/container.tsx
Normal file
@@ -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;
|
||||
45
packages/frontend/component/src/ui/switch/index.css.ts
Normal file
45
packages/frontend/component/src/ui/switch/index.css.ts
Normal file
@@ -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%)',
|
||||
},
|
||||
},
|
||||
});
|
||||
1
packages/frontend/component/src/ui/switch/index.ts
Normal file
1
packages/frontend/component/src/ui/switch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './switch';
|
||||
53
packages/frontend/component/src/ui/switch/switch.tsx
Normal file
53
packages/frontend/component/src/ui/switch/switch.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
13
packages/frontend/component/src/ui/table/index.ts
Normal file
13
packages/frontend/component/src/ui/table/index.ts
Normal file
@@ -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';
|
||||
10
packages/frontend/component/src/ui/table/interface.ts
Normal file
10
packages/frontend/component/src/ui/table/interface.ts
Normal file
@@ -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>;
|
||||
112
packages/frontend/component/src/ui/table/styles.ts
Normal file
112
packages/frontend/component/src/ui/table/styles.ts
Normal file
@@ -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)',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
4
packages/frontend/component/src/ui/table/table-body.tsx
Normal file
4
packages/frontend/component/src/ui/table/table-body.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import { StyledTableBody } from './styles';
|
||||
export const TableBody = StyledTableBody;
|
||||
|
||||
export default TableBody;
|
||||
4
packages/frontend/component/src/ui/table/table-cell.tsx
Normal file
4
packages/frontend/component/src/ui/table/table-cell.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import { StyledTableCell } from './styles';
|
||||
export const TableCell = StyledTableCell;
|
||||
|
||||
export default TableCell;
|
||||
5
packages/frontend/component/src/ui/table/table-head.tsx
Normal file
5
packages/frontend/component/src/ui/table/table-head.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { StyledTableHead } from './styles';
|
||||
|
||||
export const TableHead = StyledTableHead;
|
||||
|
||||
export default TableHead;
|
||||
5
packages/frontend/component/src/ui/table/table-row.tsx
Normal file
5
packages/frontend/component/src/ui/table/table-row.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { StyledTBodyRow, StyledTHeadRow } from './styles';
|
||||
export const TableHeadRow = StyledTHeadRow;
|
||||
export const TableBodyRow = StyledTBodyRow;
|
||||
|
||||
export default TableHeadRow;
|
||||
5
packages/frontend/component/src/ui/table/table.tsx
Normal file
5
packages/frontend/component/src/ui/table/table.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { StyledTable } from './styles';
|
||||
|
||||
export const Table = StyledTable;
|
||||
|
||||
export default Table;
|
||||
1
packages/frontend/component/src/ui/toast/index.ts
Normal file
1
packages/frontend/component/src/ui/toast/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './toast';
|
||||
142
packages/frontend/component/src/ui/toast/toast.ts
Normal file
142
packages/frontend/component/src/ui/toast/toast.ts
Normal file
@@ -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;
|
||||
3
packages/frontend/component/src/ui/tree-view/index.ts
Normal file
3
packages/frontend/component/src/ui/tree-view/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './tree-node';
|
||||
export * from './tree-view';
|
||||
export * from './types';
|
||||
44
packages/frontend/component/src/ui/tree-view/styles.ts
Normal file
44
packages/frontend/component/src/ui/tree-view/styles.ts
Normal file
@@ -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,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
106
packages/frontend/component/src/ui/tree-view/tree-node.tsx
Normal file
106
packages/frontend/component/src/ui/tree-view/tree-node.tsx
Normal file
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
126
packages/frontend/component/src/ui/tree-view/tree-view.tsx
Normal file
126
packages/frontend/component/src/ui/tree-view/tree-view.tsx
Normal file
@@ -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;
|
||||
72
packages/frontend/component/src/ui/tree-view/types.ts
Normal file
72
packages/frontend/component/src/ui/tree-view/types.ts
Normal file
@@ -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'>;
|
||||
37
packages/frontend/component/src/ui/tree-view/utils.ts
Normal file
37
packages/frontend/component/src/ui/tree-view/utils.ts
Normal file
@@ -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