mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 05:14:54 +00:00
feat(core): doc database properties (#8520)
fix AF-1454
1. move inline tags editor to components
2. add progress component
3. adjust doc properties styles for desktop
4. subscribe bs database links and display in doc info
5. move update/create dates to doc info
6. a trivial e2e test
<div class='graphite__hidden'>
<div>🎥 Video uploaded on Graphite:</div>
<a href="https://app.graphite.dev/media/video/T2klNLEk0wxLh4NRDzhk/eed266c1-fdac-4f0e-baa9-4aa00d14a2e8.mp4">
<img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/T2klNLEk0wxLh4NRDzhk/eed266c1-fdac-4f0e-baa9-4aa00d14a2e8.mp4">
</a>
</div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/eed266c1-fdac-4f0e-baa9-4aa00d14a2e8.mp4">10月23日.mp4</video>
This commit is contained in:
1
packages/frontend/component/src/ui/progress/index.ts
Normal file
1
packages/frontend/component/src/ui/progress/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './progress';
|
||||
71
packages/frontend/component/src/ui/progress/progress.css.ts
Normal file
71
packages/frontend/component/src/ui/progress/progress.css.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { createVar, style } from '@vanilla-extract/css';
|
||||
|
||||
const progressHeight = createVar();
|
||||
|
||||
export const root = style({
|
||||
height: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: 240,
|
||||
gap: 12,
|
||||
vars: {
|
||||
[progressHeight]: '10px',
|
||||
},
|
||||
});
|
||||
|
||||
export const progress = style({
|
||||
height: progressHeight,
|
||||
flex: 1,
|
||||
background: cssVarV2('layer/background/hoverOverlay'),
|
||||
borderRadius: 5,
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const sliderRoot = style({
|
||||
height: progressHeight,
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
});
|
||||
|
||||
export const thumb = style({
|
||||
width: '4px',
|
||||
height: `calc(${progressHeight} + 2px)`,
|
||||
transform: 'translateY(-1px)',
|
||||
borderRadius: '2px',
|
||||
display: 'block',
|
||||
background: cssVarV2('layer/insideBorder/primaryBorder'),
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
[`${root}:hover &, &:is(:focus-visible, :focus-within)`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const label = style({
|
||||
width: '40px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
});
|
||||
|
||||
export const indicator = style({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
borderRadius: 5,
|
||||
background: cssVarV2('toast/iconState/regular'),
|
||||
transition: 'background 0.2s ease-in-out',
|
||||
selectors: {
|
||||
[`${root}:hover &, &:has(${thumb}:is(:focus-visible, :focus-within, :active))`]:
|
||||
{
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
[`[data-state="complete"]&`]: {
|
||||
background: cssVarV2('status/success'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { ProgressProps } from './progress';
|
||||
import { Progress } from './progress';
|
||||
|
||||
export default {
|
||||
title: 'UI/Progress',
|
||||
component: Progress,
|
||||
} satisfies Meta<typeof Progress>;
|
||||
|
||||
const Template: StoryFn<ProgressProps> = () => {
|
||||
const [value, setValue] = useState<number>(30);
|
||||
return (
|
||||
<Progress style={{ width: '200px' }} value={value} onChange={setValue} />
|
||||
);
|
||||
};
|
||||
|
||||
export const Default: StoryFn<ProgressProps> = Template.bind(undefined);
|
||||
51
packages/frontend/component/src/ui/progress/progress.tsx
Normal file
51
packages/frontend/component/src/ui/progress/progress.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as RadixProgress from '@radix-ui/react-progress';
|
||||
import * as RadixSlider from '@radix-ui/react-slider';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import * as styles from './progress.css';
|
||||
|
||||
export interface ProgressProps {
|
||||
/**
|
||||
* The value of the progress bar.
|
||||
* A value between 0 and 100.
|
||||
*/
|
||||
value: number;
|
||||
onChange?: (value: number) => void;
|
||||
onBlur?: () => void;
|
||||
readonly?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const Progress = ({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
readonly,
|
||||
className,
|
||||
style,
|
||||
}: ProgressProps) => {
|
||||
return (
|
||||
<div className={clsx(styles.root, className)} style={style}>
|
||||
<RadixProgress.Root className={styles.progress} value={value}>
|
||||
<RadixProgress.Indicator
|
||||
className={styles.indicator}
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
{!readonly ? (
|
||||
<RadixSlider.Root
|
||||
className={styles.sliderRoot}
|
||||
min={0}
|
||||
max={100}
|
||||
value={[value]}
|
||||
onValueChange={values => onChange?.(values[0])}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
<RadixSlider.Thumb className={styles.thumb} />
|
||||
</RadixSlider.Root>
|
||||
) : null}
|
||||
</RadixProgress.Root>
|
||||
<div className={styles.label}>{value}%</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -129,15 +129,13 @@ export const propertyValueContainer = style({
|
||||
color: cssVarV2('text/placeholder'),
|
||||
},
|
||||
selectors: {
|
||||
'&[data-readonly="false"]': {
|
||||
'&[data-readonly="false"][data-hoverable="true"]': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'&[data-readonly="false"]:hover': {
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
'&[data-readonly="false"]:focus-within': {
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
'&[data-readonly="false"][data-hoverable="true"]:is(:hover, :focus-within)':
|
||||
{
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -162,3 +160,67 @@ globalStyle(`${tableButton} svg`, {
|
||||
globalStyle(`${tableButton}:hover svg`, {
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
|
||||
export const section = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
export const sectionHeader = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '4px 6px',
|
||||
minHeight: 30,
|
||||
});
|
||||
|
||||
export const sectionHeaderTrigger = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const sectionHeaderIcon = style({
|
||||
width: 16,
|
||||
height: 16,
|
||||
fontSize: 16,
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
|
||||
export const sectionHeaderName = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
color: cssVarV2('text/secondary'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const sectionCollapsedIcon = style({
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
color: cssVarV2('icon/primary'),
|
||||
fontSize: 20,
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
transform: 'rotate(90deg)',
|
||||
color: cssVarV2('icon/secondary'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const sectionContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
selectors: {
|
||||
'&[hidden]': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@ import { FrameIcon } from '@blocksuite/icons/rc';
|
||||
import { useDraggable, useDropTarget } from '../dnd';
|
||||
import { MenuItem } from '../menu';
|
||||
import {
|
||||
PropertyCollapsible,
|
||||
PropertyCollapsibleContent,
|
||||
PropertyCollapsibleSection,
|
||||
PropertyName,
|
||||
PropertyRoot,
|
||||
PropertyValue,
|
||||
@@ -100,9 +101,9 @@ export const HideEmptyProperty = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const BasicPropertyCollapsible = () => {
|
||||
export const BasicPropertyCollapsibleContent = () => {
|
||||
return (
|
||||
<PropertyCollapsible collapsible>
|
||||
<PropertyCollapsibleContent collapsible>
|
||||
<PropertyRoot>
|
||||
<PropertyName name="Always show" icon={<FrameIcon />} />
|
||||
<PropertyValue>Value</PropertyValue>
|
||||
@@ -115,13 +116,24 @@ export const BasicPropertyCollapsible = () => {
|
||||
<PropertyName name="Hide" icon={<FrameIcon />} />
|
||||
<PropertyValue>Value</PropertyValue>
|
||||
</PropertyRoot>
|
||||
</PropertyCollapsible>
|
||||
</PropertyCollapsibleContent>
|
||||
);
|
||||
};
|
||||
|
||||
export const BasicPropertyCollapsibleSection = () => {
|
||||
return (
|
||||
<PropertyCollapsibleSection
|
||||
icon={<FrameIcon />}
|
||||
title="Collapsible Section"
|
||||
>
|
||||
<BasicPropertyCollapsibleContent />
|
||||
</PropertyCollapsibleSection>
|
||||
);
|
||||
};
|
||||
|
||||
export const PropertyCollapsibleCustomButton = () => {
|
||||
return (
|
||||
<PropertyCollapsible
|
||||
<PropertyCollapsibleContent
|
||||
collapsible
|
||||
collapseButtonText={({ hide, isCollapsed }) =>
|
||||
`${isCollapsed ? 'Show' : 'Hide'} ${hide} properties`
|
||||
@@ -139,6 +151,6 @@ export const PropertyCollapsibleCustomButton = () => {
|
||||
<PropertyName name="Hide" icon={<FrameIcon />} />
|
||||
<PropertyValue>Value</PropertyValue>
|
||||
</PropertyRoot>
|
||||
</PropertyCollapsible>
|
||||
</PropertyCollapsibleContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
||||
import { ArrowDownSmallIcon, ArrowUpSmallIcon } from '@blocksuite/icons/rc';
|
||||
import {
|
||||
ArrowDownSmallIcon,
|
||||
ArrowUpSmallIcon,
|
||||
ToggleExpandIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
createContext,
|
||||
@@ -24,7 +29,92 @@ const PropertyTableContext = createContext<{
|
||||
showAllHide: boolean;
|
||||
} | null>(null);
|
||||
|
||||
export const PropertyCollapsible = forwardRef<
|
||||
export const PropertyCollapsibleSection = forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithChildren<{
|
||||
defaultCollapsed?: boolean;
|
||||
icon?: ReactNode;
|
||||
title: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
collapsed?: boolean;
|
||||
onCollapseChange?: (collapsed: boolean) => void;
|
||||
}> &
|
||||
HTMLProps<HTMLDivElement>
|
||||
>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
defaultCollapsed = false,
|
||||
collapsed,
|
||||
onCollapseChange,
|
||||
icon,
|
||||
title,
|
||||
suffix,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [internalCollapsed, setInternalCollapsed] =
|
||||
useState(defaultCollapsed);
|
||||
|
||||
const handleCollapse = useCallback(
|
||||
(open: boolean) => {
|
||||
setInternalCollapsed(!open);
|
||||
onCollapseChange?.(!open);
|
||||
},
|
||||
[onCollapseChange]
|
||||
);
|
||||
|
||||
const finalCollapsed =
|
||||
collapsed !== undefined ? collapsed : internalCollapsed;
|
||||
|
||||
return (
|
||||
<Collapsible.Root
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={clsx(styles.section, className)}
|
||||
open={!finalCollapsed}
|
||||
onOpenChange={handleCollapse}
|
||||
data-testid="property-collapsible-section"
|
||||
>
|
||||
<div
|
||||
className={styles.sectionHeader}
|
||||
data-testid="property-collapsible-section-header"
|
||||
>
|
||||
<Collapsible.Trigger
|
||||
role="button"
|
||||
data-testid="property-collapsible-section-trigger"
|
||||
className={styles.sectionHeaderTrigger}
|
||||
>
|
||||
{icon && <div className={styles.sectionHeaderIcon}>{icon}</div>}
|
||||
<div
|
||||
data-collapsed={finalCollapsed}
|
||||
className={styles.sectionHeaderName}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<ToggleExpandIcon
|
||||
className={styles.sectionCollapsedIcon}
|
||||
data-collapsed={finalCollapsed}
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
{suffix}
|
||||
</div>
|
||||
<Collapsible.Content
|
||||
data-testid="property-collapsible-section-content"
|
||||
className={styles.sectionContent}
|
||||
>
|
||||
{children}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
PropertyCollapsibleSection.displayName = 'PropertyCollapsibleSection';
|
||||
|
||||
export const PropertyCollapsibleContent = forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithChildren<{
|
||||
collapsible?: boolean;
|
||||
@@ -124,7 +214,7 @@ export const PropertyCollapsible = forwardRef<
|
||||
}
|
||||
);
|
||||
|
||||
PropertyCollapsible.displayName = 'PropertyCollapsible';
|
||||
PropertyCollapsibleContent.displayName = 'PropertyCollapsible';
|
||||
|
||||
const PropertyRootContext = createContext<{
|
||||
mountValue: (payload: { isEmpty: boolean }) => () => void;
|
||||
@@ -249,28 +339,38 @@ export const PropertyName = ({
|
||||
|
||||
export const PropertyValue = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ readonly?: boolean; isEmpty?: boolean } & HTMLProps<HTMLDivElement>
|
||||
>(({ children, className, readonly, isEmpty, ...props }, ref) => {
|
||||
const context = useContext(PropertyRootContext);
|
||||
{
|
||||
readonly?: boolean;
|
||||
isEmpty?: boolean;
|
||||
hoverable?: boolean;
|
||||
} & HTMLProps<HTMLDivElement>
|
||||
>(
|
||||
(
|
||||
{ children, className, readonly, isEmpty, hoverable = true, ...props },
|
||||
ref
|
||||
) => {
|
||||
const context = useContext(PropertyRootContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (context) {
|
||||
return context.mountValue({ isEmpty: !!isEmpty });
|
||||
}
|
||||
return;
|
||||
}, [context, isEmpty]);
|
||||
useLayoutEffect(() => {
|
||||
if (context) {
|
||||
return context.mountValue({ isEmpty: !!isEmpty });
|
||||
}
|
||||
return;
|
||||
}, [context, isEmpty]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(styles.propertyValueContainer, className)}
|
||||
data-readonly={readonly ? 'true' : 'false'}
|
||||
data-empty={isEmpty ? 'true' : 'false'}
|
||||
data-property-value
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(styles.propertyValueContainer, className)}
|
||||
data-readonly={readonly ? 'true' : 'false'}
|
||||
data-empty={isEmpty ? 'true' : 'false'}
|
||||
data-hoverable={hoverable ? 'true' : 'false'}
|
||||
data-property-value
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
PropertyValue.displayName = 'PropertyValue';
|
||||
|
||||
3
packages/frontend/component/src/ui/tags/index.ts
Normal file
3
packages/frontend/component/src/ui/tags/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './tag';
|
||||
export * from './tags-editor';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,8 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const inlineTagsContainer = style({
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
flexWrap: 'wrap',
|
||||
width: '100%',
|
||||
});
|
||||
47
packages/frontend/component/src/ui/tags/inline-tag-list.tsx
Normal file
47
packages/frontend/component/src/ui/tags/inline-tag-list.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { HTMLAttributes, PropsWithChildren } from 'react';
|
||||
|
||||
import * as styles from './inline-tag-list.css';
|
||||
import { TagItem } from './tag';
|
||||
import type { TagLike } from './types';
|
||||
|
||||
interface InlineTagListProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||
onRemoved?: (id: string) => void;
|
||||
tags: TagLike[];
|
||||
tagMode: 'inline-tag' | 'db-label';
|
||||
focusedIndex?: number;
|
||||
}
|
||||
|
||||
export const InlineTagList = ({
|
||||
children,
|
||||
focusedIndex,
|
||||
tags,
|
||||
onRemoved,
|
||||
tagMode,
|
||||
}: PropsWithChildren<InlineTagListProps>) => {
|
||||
return (
|
||||
<div className={styles.inlineTagsContainer} data-testid="inline-tags-list">
|
||||
{tags.map((tag, idx) => {
|
||||
if (!tag) {
|
||||
return null;
|
||||
}
|
||||
const handleRemoved = onRemoved
|
||||
? () => {
|
||||
onRemoved?.(tag.id);
|
||||
}
|
||||
: undefined;
|
||||
return (
|
||||
<TagItem
|
||||
key={tag.id}
|
||||
idx={idx}
|
||||
focused={focusedIndex === idx}
|
||||
onRemoved={handleRemoved}
|
||||
mode={tagMode}
|
||||
tag={tag}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
3
packages/frontend/component/src/ui/tags/readme.md
Normal file
3
packages/frontend/component/src/ui/tags/readme.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Tags Editor
|
||||
|
||||
A common module for both page and database tags editing (serviceless).
|
||||
133
packages/frontend/component/src/ui/tags/styles.css.ts
Normal file
133
packages/frontend/component/src/ui/tags/styles.css.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const tagsInlineEditor = style({
|
||||
selectors: {
|
||||
'&[data-empty=true]': {
|
||||
color: cssVar('placeholderColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tagsEditorRoot = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const inlineTagsContainer = style({
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
flexWrap: 'wrap',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const tagsMenu = style({
|
||||
padding: 0,
|
||||
position: 'relative',
|
||||
top: 'calc(-3.5px + var(--radix-popper-anchor-height) * -1)',
|
||||
left: '-3.5px',
|
||||
width: 'calc(var(--radix-popper-anchor-width) + 16px)',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const tagsEditorSelectedTags = style({
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
flexWrap: 'wrap',
|
||||
padding: '10px 12px',
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
minHeight: 42,
|
||||
});
|
||||
|
||||
export const searchInput = style({
|
||||
flexGrow: 1,
|
||||
padding: '10px 0',
|
||||
margin: '-10px 0',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
color: 'inherit',
|
||||
backgroundColor: 'transparent',
|
||||
'::placeholder': {
|
||||
color: cssVar('placeholderColor'),
|
||||
},
|
||||
});
|
||||
|
||||
export const tagsEditorTagsSelector = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: '0 8px 8px 8px',
|
||||
maxHeight: '400px',
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
export const tagsEditorTagsSelectorHeader = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 500,
|
||||
color: cssVar('textSecondaryColor'),
|
||||
});
|
||||
|
||||
export const tagSelectorTagsScrollContainer = style({
|
||||
overflowX: 'hidden',
|
||||
position: 'relative',
|
||||
maxHeight: '200px',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const tagSelectorItem = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px',
|
||||
height: '34px',
|
||||
gap: 8,
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
selectors: {
|
||||
'&[data-focused=true]': {
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tagEditIcon = style({
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
[`${tagSelectorItem}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`${tagEditIcon}[data-state=open]`, {
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
export const spacer = style({
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
export const menuItemListScrollable = style({});
|
||||
|
||||
export const menuItemListScrollbar = style({
|
||||
transform: 'translateX(4px)',
|
||||
});
|
||||
|
||||
export const menuItemList = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
globalStyle(`${menuItemList}[data-radix-scroll-area-viewport] > div`, {
|
||||
display: 'table !important',
|
||||
});
|
||||
32
packages/frontend/component/src/ui/tags/tag-edit-menu.css.ts
Normal file
32
packages/frontend/component/src/ui/tags/tag-edit-menu.css.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const menuItemListScrollable = style({});
|
||||
|
||||
export const menuItemListScrollbar = style({
|
||||
transform: 'translateX(4px)',
|
||||
});
|
||||
|
||||
export const menuItemList = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
globalStyle(`${menuItemList}[data-radix-scroll-area-viewport] > div`, {
|
||||
display: 'table !important',
|
||||
});
|
||||
|
||||
export const tagColorIconWrapper = style({
|
||||
width: 20,
|
||||
height: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
export const tagColorIcon = style({
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
});
|
||||
113
packages/frontend/component/src/ui/tags/tag-edit-menu.tsx
Normal file
113
packages/frontend/component/src/ui/tags/tag-edit-menu.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { DeleteIcon, TagsIcon } from '@blocksuite/icons/rc';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Input from '../input';
|
||||
import { Menu, MenuItem, type MenuProps, MenuSeparator } from '../menu';
|
||||
import { Scrollable } from '../scrollbar';
|
||||
import * as styles from './tag-edit-menu.css';
|
||||
import type { TagColor, TagLike } from './types';
|
||||
|
||||
type TagEditMenuProps = PropsWithChildren<{
|
||||
onTagDelete: (tagId: string) => void;
|
||||
colors: TagColor[];
|
||||
tag: TagLike;
|
||||
onTagChange: (property: keyof TagLike, value: string) => void;
|
||||
jumpToTag?: (tagId: string) => void;
|
||||
}>;
|
||||
|
||||
export const TagEditMenu = ({
|
||||
tag,
|
||||
onTagDelete,
|
||||
children,
|
||||
jumpToTag,
|
||||
colors,
|
||||
onTagChange,
|
||||
}: TagEditMenuProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
const menuProps = useMemo(() => {
|
||||
const updateTagName = (name: string) => {
|
||||
if (name.trim() === '') {
|
||||
return;
|
||||
}
|
||||
onTagChange('value', name);
|
||||
};
|
||||
|
||||
return {
|
||||
contentOptions: {
|
||||
onClick(e) {
|
||||
e.stopPropagation();
|
||||
},
|
||||
},
|
||||
items: (
|
||||
<>
|
||||
<Input
|
||||
defaultValue={tag.value}
|
||||
onBlur={e => {
|
||||
updateTagName(e.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
updateTagName(e.currentTarget.value);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
placeholder={t['Untitled']()}
|
||||
/>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
prefixIcon={<DeleteIcon />}
|
||||
type="danger"
|
||||
onClick={() => {
|
||||
tag?.id ? onTagDelete(tag.id) : null;
|
||||
}}
|
||||
>
|
||||
{t['Delete']()}
|
||||
</MenuItem>
|
||||
{jumpToTag ? (
|
||||
<MenuItem
|
||||
prefixIcon={<TagsIcon />}
|
||||
onClick={() => {
|
||||
jumpToTag(tag.id);
|
||||
}}
|
||||
>
|
||||
{t['com.affine.page-properties.tags.open-tags-page']()}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuSeparator />
|
||||
<Scrollable.Root>
|
||||
<Scrollable.Viewport className={styles.menuItemList}>
|
||||
{colors.map(({ name, value: color }, i) => (
|
||||
<MenuItem
|
||||
key={i}
|
||||
checked={tag.color === color}
|
||||
prefixIcon={
|
||||
<div key={i} className={styles.tagColorIconWrapper}>
|
||||
<div
|
||||
className={styles.tagColorIcon}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onClick={() => {
|
||||
onTagChange('color', color);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
))}
|
||||
<Scrollable.Scrollbar className={styles.menuItemListScrollbar} />
|
||||
</Scrollable.Viewport>
|
||||
</Scrollable.Root>
|
||||
</>
|
||||
),
|
||||
} satisfies Partial<MenuProps>;
|
||||
}, [tag, t, jumpToTag, colors, onTagChange, onTagDelete]);
|
||||
|
||||
return <Menu {...menuProps}>{children}</Menu>;
|
||||
};
|
||||
168
packages/frontend/component/src/ui/tags/tag.css.ts
Normal file
168
packages/frontend/component/src/ui/tags/tag.css.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { createVar, style } from '@vanilla-extract/css';
|
||||
export const hoverMaxWidth = createVar();
|
||||
|
||||
export const tagColorVar = createVar();
|
||||
|
||||
export const root = style({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: '32px',
|
||||
});
|
||||
|
||||
export const tagsContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
export const tagsScrollContainer = style([
|
||||
tagsContainer,
|
||||
{
|
||||
overflowX: 'hidden',
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
gap: '8px',
|
||||
},
|
||||
]);
|
||||
export const tagsListContainer = style([
|
||||
tagsContainer,
|
||||
{
|
||||
flexWrap: 'wrap',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: '4px',
|
||||
},
|
||||
]);
|
||||
export const innerContainer = style({
|
||||
display: 'flex',
|
||||
columnGap: '8px',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
transition: 'all 0.2s 0.3s ease-in-out',
|
||||
selectors: {
|
||||
[`${root}:hover &`]: {
|
||||
maxWidth: hoverMaxWidth,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// background with linear gradient hack
|
||||
export const innerBackdrop = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '100%',
|
||||
opacity: 0,
|
||||
transition: 'all 0.2s',
|
||||
background: `linear-gradient(90deg, transparent 0%, ${cssVar(
|
||||
'hoverColorFilled'
|
||||
)} 40%)`,
|
||||
selectors: {
|
||||
[`${root}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
export const tag = style({
|
||||
height: '22px',
|
||||
display: 'flex',
|
||||
minWidth: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
':last-child': {
|
||||
minWidth: 'max-content',
|
||||
},
|
||||
});
|
||||
export const tagInnerWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 8px',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
borderColor: cssVar('borderColor'),
|
||||
selectors: {
|
||||
'&[data-focused=true]': {
|
||||
borderColor: cssVar('primaryColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
export const tagInlineMode = style([
|
||||
tagInnerWrapper,
|
||||
{
|
||||
fontSize: 'inherit',
|
||||
borderRadius: '10px',
|
||||
columnGap: '4px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
maxWidth: '128px',
|
||||
height: '100%',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
]);
|
||||
export const tagListItemMode = style([
|
||||
tag,
|
||||
{
|
||||
fontSize: cssVar('fontSm'),
|
||||
padding: '4px 12px',
|
||||
columnGap: '8px',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
height: '30px',
|
||||
},
|
||||
]);
|
||||
export const tagLabelMode = style([
|
||||
tag,
|
||||
{
|
||||
fontSize: cssVar('fontSm'),
|
||||
background: tagColorVar,
|
||||
padding: '0 8px',
|
||||
borderRadius: 4,
|
||||
border: `1px solid ${cssVarV2('database/border')}`,
|
||||
gap: 4,
|
||||
selectors: {
|
||||
'&[data-focused=true]': {
|
||||
borderColor: cssVar('primaryColor'),
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const showMoreTag = style({
|
||||
fontSize: cssVar('fontH5'),
|
||||
right: 0,
|
||||
position: 'sticky',
|
||||
display: 'inline-flex',
|
||||
});
|
||||
export const tagIndicator = style({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
background: tagColorVar,
|
||||
});
|
||||
export const tagLabel = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
});
|
||||
|
||||
export const tagRemove = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
74
packages/frontend/component/src/ui/tags/tag.tsx
Normal file
74
packages/frontend/component/src/ui/tags/tag.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { CloseIcon } from '@blocksuite/icons/rc';
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import clsx from 'clsx';
|
||||
import { type MouseEventHandler, useCallback } from 'react';
|
||||
|
||||
import * as styles from './tag.css';
|
||||
import type { TagLike } from './types';
|
||||
|
||||
export interface TagItemProps {
|
||||
tag: TagLike;
|
||||
idx?: number;
|
||||
maxWidth?: number | string;
|
||||
// @todo(pengx17): better naming
|
||||
mode: 'inline-tag' | 'list-tag' | 'db-label';
|
||||
focused?: boolean;
|
||||
onRemoved?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const TagItem = ({
|
||||
tag,
|
||||
idx,
|
||||
mode,
|
||||
focused,
|
||||
onRemoved,
|
||||
style,
|
||||
maxWidth,
|
||||
}: TagItemProps) => {
|
||||
const { value, color, id } = tag;
|
||||
const handleRemove: MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
e => {
|
||||
e.stopPropagation();
|
||||
onRemoved?.();
|
||||
},
|
||||
[onRemoved]
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={styles.tag}
|
||||
data-idx={idx}
|
||||
data-tag-id={id}
|
||||
data-tag-value={value}
|
||||
title={value}
|
||||
style={{
|
||||
...style,
|
||||
...assignInlineVars({
|
||||
[styles.tagColorVar]: color,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ maxWidth: maxWidth }}
|
||||
data-focused={focused}
|
||||
className={clsx({
|
||||
[styles.tagInlineMode]: mode === 'inline-tag',
|
||||
[styles.tagListItemMode]: mode === 'list-tag',
|
||||
[styles.tagLabelMode]: mode === 'db-label',
|
||||
})}
|
||||
>
|
||||
{mode !== 'db-label' ? <div className={styles.tagIndicator} /> : null}
|
||||
<div className={styles.tagLabel}>{value}</div>
|
||||
{onRemoved ? (
|
||||
<div
|
||||
data-testid="remove-tag-button"
|
||||
className={styles.tagRemove}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<CloseIcon />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
330
packages/frontend/component/src/ui/tags/tags-editor.tsx
Normal file
330
packages/frontend/component/src/ui/tags/tags-editor.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
import { clamp } from 'lodash-es';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
|
||||
|
||||
import { IconButton } from '../button';
|
||||
import { RowInput } from '../input';
|
||||
import { Menu } from '../menu';
|
||||
import { Scrollable } from '../scrollbar';
|
||||
import { InlineTagList } from './inline-tag-list';
|
||||
import * as styles from './styles.css';
|
||||
import { TagItem } from './tag';
|
||||
import { TagEditMenu } from './tag-edit-menu';
|
||||
import type { TagColor, TagLike } from './types';
|
||||
|
||||
export interface TagsEditorProps {
|
||||
tags: TagLike[]; // candidates to show in the tag dropdown
|
||||
selectedTags: string[];
|
||||
onCreateTag: (name: string, color: string) => TagLike;
|
||||
onSelectTag: (tagId: string) => void; // activate tag
|
||||
onDeselectTag: (tagId: string) => void; // deactivate tag
|
||||
tagColors: TagColor[];
|
||||
onTagChange: (id: string, property: keyof TagLike, value: string) => void;
|
||||
onDeleteTag: (id: string) => void; // a candidate to be deleted
|
||||
jumpToTag?: (id: string) => void;
|
||||
tagMode: 'inline-tag' | 'db-label';
|
||||
}
|
||||
|
||||
export interface TagsInlineEditorProps extends TagsEditorProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
type TagOption = TagLike | { readonly create: true; readonly value: string };
|
||||
|
||||
const isCreateNewTag = (
|
||||
tagOption: TagOption
|
||||
): tagOption is { readonly create: true; readonly value: string } => {
|
||||
return 'create' in tagOption;
|
||||
};
|
||||
|
||||
export const TagsEditor = ({
|
||||
tags,
|
||||
selectedTags,
|
||||
onSelectTag,
|
||||
onDeselectTag,
|
||||
onCreateTag,
|
||||
tagColors,
|
||||
onDeleteTag: onTagDelete,
|
||||
onTagChange,
|
||||
jumpToTag,
|
||||
tagMode,
|
||||
}: TagsEditorProps) => {
|
||||
const t = useI18n();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const filteredTags = tags.filter(tag => tag.value.includes(inputValue));
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const exactMatch = filteredTags.find(tag => tag.value === inputValue);
|
||||
const showCreateTag = !exactMatch && inputValue.trim();
|
||||
|
||||
// tag option candidates to show in the tag dropdown
|
||||
const tagOptions: TagOption[] = useMemo(() => {
|
||||
if (showCreateTag) {
|
||||
return [{ create: true, value: inputValue } as const, ...filteredTags];
|
||||
} else {
|
||||
return filteredTags;
|
||||
}
|
||||
}, [filteredTags, inputValue, showCreateTag]);
|
||||
|
||||
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
|
||||
const [focusedInlineIndex, setFocusedInlineIndex] = useState<number>(
|
||||
selectedTags.length
|
||||
);
|
||||
|
||||
// -1: no focus
|
||||
const safeFocusedIndex = clamp(focusedIndex, -1, tagOptions.length - 1);
|
||||
// inline tags focus index can go beyond the length of tagIds
|
||||
// using -1 and tagIds.length to make keyboard navigation easier
|
||||
const safeInlineFocusedIndex = clamp(
|
||||
focusedInlineIndex,
|
||||
-1,
|
||||
selectedTags.length
|
||||
);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onInputChange = useCallback((value: string) => {
|
||||
setInputValue(value);
|
||||
}, []);
|
||||
|
||||
const onToggleTag = useCallback(
|
||||
(id: string) => {
|
||||
if (!selectedTags.includes(id)) {
|
||||
onSelectTag(id);
|
||||
} else {
|
||||
onDeselectTag(id);
|
||||
}
|
||||
},
|
||||
[selectedTags, onSelectTag, onDeselectTag]
|
||||
);
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const [nextColor, rotateNextColor] = useReducer(
|
||||
color => {
|
||||
const idx = tagColors.findIndex(c => c.value === color);
|
||||
return tagColors[(idx + 1) % tagColors.length].value;
|
||||
},
|
||||
tagColors[Math.floor(Math.random() * tagColors.length)].value
|
||||
);
|
||||
|
||||
const handleCreateTag = useCallback(
|
||||
(name: string) => {
|
||||
rotateNextColor();
|
||||
const newTag = onCreateTag(name.trim(), nextColor);
|
||||
return newTag.id;
|
||||
},
|
||||
[onCreateTag, nextColor]
|
||||
);
|
||||
|
||||
const onSelectTagOption = useCallback(
|
||||
(tagOption: TagOption) => {
|
||||
const id = isCreateNewTag(tagOption)
|
||||
? handleCreateTag(tagOption.value)
|
||||
: tagOption.id;
|
||||
onToggleTag(id);
|
||||
setInputValue('');
|
||||
focusInput();
|
||||
setFocusedIndex(-1);
|
||||
setFocusedInlineIndex(selectedTags.length + 1);
|
||||
},
|
||||
[handleCreateTag, onToggleTag, focusInput, selectedTags.length]
|
||||
);
|
||||
const onEnter = useCallback(() => {
|
||||
if (safeFocusedIndex >= 0) {
|
||||
onSelectTagOption(tagOptions[safeFocusedIndex]);
|
||||
}
|
||||
}, [onSelectTagOption, safeFocusedIndex, tagOptions]);
|
||||
|
||||
const handleUntag = useCallback(
|
||||
(id: string) => {
|
||||
onToggleTag(id);
|
||||
focusInput();
|
||||
},
|
||||
[onToggleTag, focusInput]
|
||||
);
|
||||
|
||||
const onInputKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Backspace' && inputValue === '' && selectedTags.length) {
|
||||
const index =
|
||||
safeInlineFocusedIndex < 0 ||
|
||||
safeInlineFocusedIndex >= selectedTags.length
|
||||
? selectedTags.length - 1
|
||||
: safeInlineFocusedIndex;
|
||||
const tagToRemove = selectedTags.at(index);
|
||||
if (tagToRemove) {
|
||||
onDeselectTag(tagToRemove);
|
||||
}
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const newFocusedIndex = clamp(
|
||||
safeFocusedIndex + (e.key === 'ArrowUp' ? -1 : 1),
|
||||
0,
|
||||
tagOptions.length - 1
|
||||
);
|
||||
scrollContainerRef.current
|
||||
?.querySelector(
|
||||
`.${styles.tagSelectorItem}:nth-child(${newFocusedIndex + 1})`
|
||||
)
|
||||
?.scrollIntoView({ block: 'nearest' });
|
||||
setFocusedIndex(newFocusedIndex);
|
||||
// reset inline focus
|
||||
setFocusedInlineIndex(selectedTags.length + 1);
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
const newItemToFocus =
|
||||
e.key === 'ArrowLeft'
|
||||
? safeInlineFocusedIndex - 1
|
||||
: safeInlineFocusedIndex + 1;
|
||||
|
||||
e.preventDefault();
|
||||
setFocusedInlineIndex(newItemToFocus);
|
||||
// reset tag list focus
|
||||
setFocusedIndex(-1);
|
||||
}
|
||||
},
|
||||
[
|
||||
inputValue,
|
||||
safeInlineFocusedIndex,
|
||||
selectedTags,
|
||||
onDeselectTag,
|
||||
safeFocusedIndex,
|
||||
tagOptions.length,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="tags-editor-popup" className={styles.tagsEditorRoot}>
|
||||
<div className={styles.tagsEditorSelectedTags}>
|
||||
<InlineTagList
|
||||
tagMode={tagMode}
|
||||
tags={tags.filter(tag => selectedTags.includes(tag.id))}
|
||||
focusedIndex={safeInlineFocusedIndex}
|
||||
onRemoved={handleUntag}
|
||||
>
|
||||
<RowInput
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
onKeyDown={onInputKeyDown}
|
||||
onEnter={onEnter}
|
||||
autoFocus
|
||||
className={styles.searchInput}
|
||||
placeholder="Type here ..."
|
||||
/>
|
||||
</InlineTagList>
|
||||
</div>
|
||||
<div className={styles.tagsEditorTagsSelector}>
|
||||
<div className={styles.tagsEditorTagsSelectorHeader}>
|
||||
{t['com.affine.page-properties.tags.selector-header-title']()}
|
||||
</div>
|
||||
<Scrollable.Root>
|
||||
<Scrollable.Viewport
|
||||
ref={scrollContainerRef}
|
||||
className={styles.tagSelectorTagsScrollContainer}
|
||||
>
|
||||
{tagOptions.map((tag, idx) => {
|
||||
const commonProps = {
|
||||
...(safeFocusedIndex === idx ? { focused: 'true' } : {}),
|
||||
onClick: () => onSelectTagOption(tag),
|
||||
onMouseEnter: () => setFocusedIndex(idx),
|
||||
['data-testid']: 'tag-selector-item',
|
||||
['data-focused']: safeFocusedIndex === idx,
|
||||
className: styles.tagSelectorItem,
|
||||
};
|
||||
if (isCreateNewTag(tag)) {
|
||||
return (
|
||||
<div key={tag.value + '.' + idx} {...commonProps}>
|
||||
{t['Create']()}{' '}
|
||||
<TagItem
|
||||
mode={tagMode}
|
||||
tag={{
|
||||
id: 'create-new-tag',
|
||||
value: inputValue,
|
||||
color: nextColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
{...commonProps}
|
||||
data-tag-id={tag.id}
|
||||
data-tag-value={tag.value}
|
||||
>
|
||||
<TagItem maxWidth="100%" tag={tag} mode={tagMode} />
|
||||
<div className={styles.spacer} />
|
||||
<TagEditMenu
|
||||
tag={tag}
|
||||
onTagDelete={onTagDelete}
|
||||
onTagChange={(property, value) => {
|
||||
onTagChange(tag.id, property, value);
|
||||
}}
|
||||
jumpToTag={jumpToTag}
|
||||
colors={tagColors}
|
||||
>
|
||||
<IconButton className={styles.tagEditIcon}>
|
||||
<MoreHorizontalIcon />
|
||||
</IconButton>
|
||||
</TagEditMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar style={{ transform: 'translateX(6px)' }} />
|
||||
</Scrollable.Root>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TagsInlineEditor = ({
|
||||
readonly,
|
||||
placeholder,
|
||||
className,
|
||||
...props
|
||||
}: TagsInlineEditorProps) => {
|
||||
const empty = !props.selectedTags || props.selectedTags.length === 0;
|
||||
const selectedTags = useMemo(() => {
|
||||
return props.selectedTags
|
||||
.map(id => props.tags.find(tag => tag.id === id))
|
||||
.filter(tag => tag !== undefined);
|
||||
}, [props.selectedTags, props.tags]);
|
||||
return (
|
||||
<Menu
|
||||
contentOptions={{
|
||||
side: 'bottom',
|
||||
align: 'start',
|
||||
sideOffset: 0,
|
||||
avoidCollisions: false,
|
||||
className: styles.tagsMenu,
|
||||
onClick(e) {
|
||||
e.stopPropagation();
|
||||
},
|
||||
}}
|
||||
items={<TagsEditor {...props} />}
|
||||
>
|
||||
<div
|
||||
className={clsx(styles.tagsInlineEditor, className)}
|
||||
data-empty={empty}
|
||||
data-readonly={readonly}
|
||||
>
|
||||
{empty ? (
|
||||
placeholder
|
||||
) : (
|
||||
<InlineTagList {...props} tags={selectedTags} onRemoved={undefined} />
|
||||
)}
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
120
packages/frontend/component/src/ui/tags/tags.stories.tsx
Normal file
120
packages/frontend/component/src/ui/tags/tags.stories.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { InlineTagList } from './inline-tag-list';
|
||||
import { TagItem } from './tag';
|
||||
import { TagEditMenu } from './tag-edit-menu';
|
||||
import { TagsInlineEditor } from './tags-editor';
|
||||
import type { TagColor, TagLike } from './types';
|
||||
|
||||
export default {
|
||||
title: 'UI/Tags',
|
||||
} satisfies Meta;
|
||||
|
||||
const tags: TagLike[] = [
|
||||
{ id: '1', value: 'tag', color: 'red' },
|
||||
{ id: '2', value: 'tag2', color: 'blue' },
|
||||
{ id: '3', value: 'tag3', color: 'green' },
|
||||
];
|
||||
|
||||
const tagColors: TagColor[] = [
|
||||
{ id: '1', value: 'red', name: 'Red' },
|
||||
{ id: '2', value: 'blue', name: 'Blue' },
|
||||
{ id: '3', value: 'green', name: 'Green' },
|
||||
];
|
||||
|
||||
export const Tags: StoryFn = () => {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<TagItem
|
||||
tag={{ id: '1', value: 'tag', color: 'red' }}
|
||||
mode="inline-tag"
|
||||
focused
|
||||
onRemoved={() => {
|
||||
console.log('removed');
|
||||
}}
|
||||
/>
|
||||
<TagItem
|
||||
tag={{ id: '2', value: 'tag2', color: 'blue' }}
|
||||
mode="inline-tag"
|
||||
/>
|
||||
<TagItem
|
||||
tag={{ id: '3', value: 'tag3', color: '#DCFDD7' }}
|
||||
mode="db-label"
|
||||
/>
|
||||
<TagItem
|
||||
tag={{ id: '3', value: 'tag5', color: '#DCFDD7' }}
|
||||
mode="db-label"
|
||||
focused
|
||||
onRemoved={() => {
|
||||
console.log('removed');
|
||||
}}
|
||||
/>
|
||||
<TagItem tag={{ id: '1', value: 'tag', color: 'red' }} mode="list-tag" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const InlineTagListStory: StoryFn = () => {
|
||||
return <InlineTagList tagMode="inline-tag" tags={tags} />;
|
||||
};
|
||||
|
||||
export const TagEditMenuStory: StoryFn = () => {
|
||||
return (
|
||||
<TagEditMenu
|
||||
tag={tags[0]}
|
||||
colors={tagColors}
|
||||
onTagChange={() => {}}
|
||||
onTagDelete={() => {}}
|
||||
jumpToTag={() => {}}
|
||||
>
|
||||
<div>Trigger Edit Tag Menu</div>
|
||||
</TagEditMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const TagsInlineEditorStory: StoryFn = () => {
|
||||
const [options, setOptions] = useState<TagLike[]>(tags);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>(
|
||||
options.slice(0, 1).map(item => item.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<TagsInlineEditor
|
||||
tags={options}
|
||||
tagMode="db-label"
|
||||
selectedTags={selectedTags}
|
||||
onCreateTag={(name, color) => {
|
||||
const newTag = {
|
||||
id: (options.at(-1)!.id ?? 0) + 1,
|
||||
value: name,
|
||||
color,
|
||||
};
|
||||
setOptions(prev => [...prev, newTag]);
|
||||
return newTag;
|
||||
}}
|
||||
tagColors={tagColors}
|
||||
onTagChange={(id, property, value) => {
|
||||
setOptions(prev => {
|
||||
const index = prev.findIndex(item => item.id === id);
|
||||
if (index === -1) {
|
||||
return prev;
|
||||
}
|
||||
return options.toSpliced(index, 1, {
|
||||
...options[index],
|
||||
[property]: value,
|
||||
});
|
||||
});
|
||||
}}
|
||||
onDeleteTag={tagId => {
|
||||
setOptions(prev => prev.filter(item => item.id !== tagId));
|
||||
}}
|
||||
onSelectTag={tagId => {
|
||||
setSelectedTags(prev => [...prev, tagId]);
|
||||
}}
|
||||
onDeselectTag={tagId => {
|
||||
setSelectedTags(prev => prev.filter(id => id !== tagId));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
11
packages/frontend/component/src/ui/tags/types.ts
Normal file
11
packages/frontend/component/src/ui/tags/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface TagLike {
|
||||
id: string;
|
||||
value: string; // value is the tag name
|
||||
color: string; // css color value
|
||||
}
|
||||
|
||||
export interface TagColor {
|
||||
id: string;
|
||||
value: string; // css color value
|
||||
name?: string; // display name
|
||||
}
|
||||
Reference in New Issue
Block a user