mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
fix(mobile): doc property styles (#8760)
fix AF-1582 fix AF-1671 - mobile doc info dialog styles - added ConfigModal for editing property values in modal, including: - workspace properties: text, number, tags - db properties: text, number, label, link
This commit is contained in:
@@ -162,6 +162,7 @@ export const headerNavToday = style([
|
||||
padding: '0 4px',
|
||||
borderRadius: 4,
|
||||
color: cssVar('iconColor'),
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -192,6 +193,7 @@ export const monthViewBodyCell = style([
|
||||
height: '28px',
|
||||
},
|
||||
]);
|
||||
|
||||
export const monthViewBodyCellInner = style([
|
||||
interactive,
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { RowInput } from './row-input';
|
||||
import { input, inputWrapper } from './style.css';
|
||||
import { input, inputWrapper, mobileInputWrapper } from './style.css';
|
||||
|
||||
export type InputProps = {
|
||||
disabled?: boolean;
|
||||
@@ -50,19 +50,23 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(inputWrapper, className, {
|
||||
// status
|
||||
disabled: disabled,
|
||||
'no-border': noBorder,
|
||||
// color
|
||||
error: status === 'error',
|
||||
success: status === 'success',
|
||||
warning: status === 'warning',
|
||||
default: status === 'default',
|
||||
// size
|
||||
large: size === 'large',
|
||||
'extra-large': size === 'extraLarge',
|
||||
})}
|
||||
className={clsx(
|
||||
BUILD_CONFIG.isMobileEdition ? mobileInputWrapper : inputWrapper,
|
||||
className,
|
||||
{
|
||||
// status
|
||||
disabled: disabled,
|
||||
'no-border': noBorder,
|
||||
// color
|
||||
error: status === 'error',
|
||||
success: status === 'success',
|
||||
warning: status === 'warning',
|
||||
default: status === 'default',
|
||||
// size
|
||||
large: size === 'large',
|
||||
'extra-large': size === 'extraLarge',
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
...style,
|
||||
}}
|
||||
|
||||
@@ -51,6 +51,15 @@ export const inputWrapper = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const mobileInputWrapper = style([
|
||||
inputWrapper,
|
||||
{
|
||||
height: 30,
|
||||
borderRadius: 4,
|
||||
},
|
||||
]);
|
||||
|
||||
export const input = style({
|
||||
height: '100%',
|
||||
width: '0',
|
||||
|
||||
@@ -131,6 +131,7 @@ export const MobileMenu = ({
|
||||
className={styles.menuContent}
|
||||
>
|
||||
<Button
|
||||
data-testid="mobile-menu-back-button"
|
||||
variant="plain"
|
||||
className={styles.backButton}
|
||||
prefix={<ArrowLeftSmallIcon />}
|
||||
|
||||
@@ -38,6 +38,7 @@ export const menuContent = style({
|
||||
width: '100%',
|
||||
flexShrink: 0,
|
||||
padding: '13px 0px 13px 0px',
|
||||
maxHeight: 'calc(100dvh - 32px)',
|
||||
});
|
||||
|
||||
export const mobileMenuItem = style({
|
||||
|
||||
@@ -30,7 +30,7 @@ export const MobileMenuSub = ({
|
||||
subContentOptions={contentOptions}
|
||||
title={title}
|
||||
>
|
||||
<div className={className} {...otherTriggerOptions}>
|
||||
<div role="menuitem" className={className} {...otherTriggerOptions}>
|
||||
{children}
|
||||
</div>
|
||||
</MobileMenuSubRaw>
|
||||
|
||||
@@ -131,8 +131,8 @@ export const modalContent = style({
|
||||
width: widthVar,
|
||||
height: heightVar,
|
||||
minHeight: minHeightVar,
|
||||
maxHeight: 'calc(100vh - 32px)',
|
||||
maxWidth: 'calc(100vw - 20px)',
|
||||
maxHeight: 'calc(100dvh - 32px)',
|
||||
maxWidth: 'calc(100dvw - 20px)',
|
||||
boxSizing: 'border-box',
|
||||
fontSize: cssVar('fontBase'),
|
||||
fontWeight: '400',
|
||||
|
||||
@@ -9,7 +9,7 @@ export const root = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: 240,
|
||||
width: '100%',
|
||||
gap: 12,
|
||||
vars: {
|
||||
[progressHeight]: '10px',
|
||||
|
||||
@@ -7,6 +7,7 @@ export const propertyRoot = style({
|
||||
minHeight: 32,
|
||||
position: 'relative',
|
||||
padding: '2px 0px 2px 2px',
|
||||
flexWrap: 'wrap',
|
||||
selectors: {
|
||||
'&[draggable="true"]': {
|
||||
cursor: 'grab',
|
||||
@@ -170,7 +171,7 @@ export const section = style({
|
||||
export const sectionHeader = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
gap: 20,
|
||||
padding: '4px 6px',
|
||||
minHeight: 30,
|
||||
});
|
||||
@@ -180,6 +181,7 @@ export const sectionHeaderTrigger = style({
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const sectionHeaderIcon = style({
|
||||
@@ -195,6 +197,7 @@ export const sectionHeaderName = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
color: cssVarV2('text/secondary'),
|
||||
|
||||
@@ -364,7 +364,9 @@ export const PropertyValue = forwardRef<
|
||||
className={clsx(styles.propertyValueContainer, className)}
|
||||
data-readonly={readonly ? 'true' : 'false'}
|
||||
data-empty={isEmpty ? 'true' : 'false'}
|
||||
data-hoverable={hoverable ? 'true' : 'false'}
|
||||
data-hoverable={
|
||||
hoverable && !BUILD_CONFIG.isMobileEdition ? 'true' : 'false'
|
||||
}
|
||||
data-property-value
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './tag';
|
||||
export * from './tags-editor';
|
||||
export * from './types';
|
||||
@@ -1,8 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const inlineTagsContainer = style({
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
flexWrap: 'wrap',
|
||||
width: '100%',
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
# Tags Editor
|
||||
|
||||
A common module for both page and database tags editing (serviceless).
|
||||
@@ -1,133 +0,0 @@
|
||||
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',
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
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%',
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
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>;
|
||||
};
|
||||
@@ -1,168 +0,0 @@
|
||||
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)',
|
||||
},
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,330 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
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));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
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