feat(core): allow keyboard navigation in tags inline editor (#7378)

fix AF-966

- Allow using arrowup/down to navigate the tag list candidates; press enter to add the currently focused tag option;
- Allow using arrowleft/right to navigate the inline tag list (selected) and use backspace to delete focused tag.
This commit is contained in:
pengx17 2024-07-02 14:25:51 +00:00
parent c62d79ab14
commit 2a6ea3c9c6
No known key found for this signature in database
GPG Key ID: 23F23D9E8B3971ED
6 changed files with 173 additions and 84 deletions

View File

@ -90,8 +90,10 @@ export const tagSelectorItem = style({
gap: 8, gap: 8,
cursor: 'pointer', cursor: 'pointer',
borderRadius: '4px', borderRadius: '4px',
':hover': { selectors: {
backgroundColor: cssVar('hoverColor'), '&[data-focused=true]': {
backgroundColor: cssVar('hoverColor'),
},
}, },
}); });

View File

@ -2,11 +2,13 @@ import type { MenuProps } from '@affine/component';
import { IconButton, Input, Menu, Scrollable } from '@affine/component'; import { IconButton, Input, Menu, Scrollable } from '@affine/component';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { WorkspaceLegacyProperties } from '@affine/core/modules/properties'; import { WorkspaceLegacyProperties } from '@affine/core/modules/properties';
import type { Tag } from '@affine/core/modules/tag';
import { DeleteTagConfirmModal, TagService } from '@affine/core/modules/tag'; import { DeleteTagConfirmModal, TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { DeleteIcon, MoreHorizontalIcon, TagsIcon } from '@blocksuite/icons/rc'; import { DeleteIcon, MoreHorizontalIcon, TagsIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
import { clamp } from 'lodash-es';
import type { HTMLAttributes, PropsWithChildren } from 'react'; import type { HTMLAttributes, PropsWithChildren } from 'react';
import { useCallback, useMemo, useReducer, useRef, useState } from 'react'; import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
@ -19,6 +21,7 @@ import * as styles from './tags-inline-editor.css';
interface TagsEditorProps { interface TagsEditorProps {
pageId: string; pageId: string;
readonly?: boolean; readonly?: boolean;
focusedIndex?: number;
} }
interface InlineTagsListProps interface InlineTagsListProps
@ -31,6 +34,7 @@ const InlineTagsList = ({
pageId, pageId,
readonly, readonly,
children, children,
focusedIndex,
onRemove, onRemove,
}: PropsWithChildren<InlineTagsListProps>) => { }: PropsWithChildren<InlineTagsListProps>) => {
const tagList = useService(TagService).tagList; const tagList = useService(TagService).tagList;
@ -54,6 +58,7 @@ const InlineTagsList = ({
<TagItem <TagItem
key={tagId} key={tagId}
idx={idx} idx={idx}
focused={focusedIndex === idx}
onRemoved={onRemoved} onRemoved={onRemoved}
mode="inline" mode="inline"
tag={tag} tag={tag}
@ -171,16 +176,51 @@ export const EditTagMenu = ({
return <Menu {...menuProps}>{children}</Menu>; return <Menu {...menuProps}>{children}</Menu>;
}; };
type TagOption = Tag | { 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 = ({ pageId, readonly }: TagsEditorProps) => { export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
const t = useI18n(); const t = useI18n();
const tagList = useService(TagService).tagList; const tagList = useService(TagService).tagList;
const tags = useLiveData(tagList.tags$); const tags = useLiveData(tagList.tags$);
const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId)); const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const filteredTags = useLiveData(
inputValue ? tagList.filterTagsByName$(inputValue) : tagList.tags$
);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]); const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const exactMatch = filteredTags.find(tag => tag.value$.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>(
tagIds.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, tagIds.length);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const handleCloseModal = useCallback( const handleCloseModal = useCallback(
(open: boolean) => { (open: boolean) => {
setOpen(open); setOpen(open);
@ -197,13 +237,6 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
[setOpen, setSelectedTagIds] [setOpen, setSelectedTagIds]
); );
const match = useLiveData(tagList.tagByTagValue$(inputValue));
const exactMatch = useLiveData(tagList.excactTagByValue$(inputValue));
const filteredTags = useLiveData(
inputValue ? tagList.filterTagsByName$(inputValue) : tagList.tags$
);
const onInputChange = useCallback( const onInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value); setInputValue(e.target.value);
@ -211,13 +244,19 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
[] []
); );
const onAddTag = useCallback( const onToggleTag = useCallback(
(id: string) => { (id: string) => {
const tagEntity = tagList.tags$.value.find(o => o.id === id);
if (!tagEntity) {
return;
}
if (!tagIds.includes(id)) { if (!tagIds.includes(id)) {
tags.find(o => o.id === id)?.tag(pageId); tagEntity.tag(pageId);
} else {
tagEntity.untag(pageId);
} }
}, },
[pageId, tagIds, tags] [pageId, tagIds, tagList.tags$.value]
); );
const focusInput = useCallback(() => { const focusInput = useCallback(() => {
@ -234,40 +273,76 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
const onCreateTag = useCallback( const onCreateTag = useCallback(
(name: string) => { (name: string) => {
if (!name.trim()) {
return;
}
rotateNextColor(); rotateNextColor();
const newTag = tagList.createTag(name.trim(), nextColor); const newTag = tagList.createTag(name.trim(), nextColor);
newTag.tag(pageId); return newTag.id;
}, },
[nextColor, pageId, tagList] [nextColor, tagList]
); );
const onSelectTag = useCallback( const onSelectTagOption = useCallback(
(id: string) => { (tagOption: TagOption) => {
onAddTag(id); const id = isCreateNewTag(tagOption)
? onCreateTag(tagOption.value)
: tagOption.id;
onToggleTag(id);
setInputValue(''); setInputValue('');
focusInput(); focusInput();
setFocusedIndex(-1);
setFocusedInlineIndex(tagIds.length + 1);
}, },
[focusInput, onAddTag] [onCreateTag, onToggleTag, focusInput, tagIds.length]
); );
const onInputKeyDown = useCallback( const onInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => { (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
if (exactMatch) { if (safeFocusedIndex >= 0) {
onAddTag(exactMatch.id); onSelectTagOption(tagOptions[safeFocusedIndex]);
} else {
onCreateTag(inputValue);
} }
setInputValue('');
} else if (e.key === 'Backspace' && inputValue === '' && tagIds.length) { } else if (e.key === 'Backspace' && inputValue === '' && tagIds.length) {
const lastTagId = tagIds[tagIds.length - 1]; const tagToRemove =
tags.find(tag => tag.id === lastTagId)?.untag(pageId); safeInlineFocusedIndex < 0 || safeInlineFocusedIndex >= tagIds.length
? tagIds.length - 1
: safeInlineFocusedIndex;
tags.find(item => item.id === tagIds.at(tagToRemove))?.untag(pageId);
} 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(tagIds.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, tagIds, exactMatch, onAddTag, onCreateTag, tags, pageId] [
inputValue,
tagIds,
safeFocusedIndex,
onSelectTagOption,
tagOptions,
safeInlineFocusedIndex,
tags,
pageId,
]
); );
return ( return (
@ -276,6 +351,7 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
<InlineTagsList <InlineTagsList
pageId={pageId} pageId={pageId}
readonly={readonly} readonly={readonly}
focusedIndex={safeInlineFocusedIndex}
onRemove={focusInput} onRemove={focusInput}
> >
<input <input
@ -295,45 +371,46 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
</div> </div>
<Scrollable.Root> <Scrollable.Root>
<Scrollable.Viewport <Scrollable.Viewport
ref={scrollContainerRef}
className={styles.tagSelectorTagsScrollContainer} className={styles.tagSelectorTagsScrollContainer}
> >
{filteredTags.map(tag => { {tagOptions.map((tag, idx) => {
return ( const commonProps = {
<div focused: safeFocusedIndex === idx,
key={tag.id} onClick: () => onSelectTagOption(tag),
className={styles.tagSelectorItem} onMouseEnter: () => setFocusedIndex(idx),
data-testid="tag-selector-item" ['data-testid']: 'tag-selector-item',
data-tag-id={tag.id} ['data-focused']: safeFocusedIndex === idx,
data-tag-value={tag.value$} className: styles.tagSelectorItem,
onClick={() => { };
onSelectTag(tag.id); if (isCreateNewTag(tag)) {
}} return (
> <div key={tag.value + '.' + idx} {...commonProps}>
<TagItem maxWidth="100%" tag={tag} mode="inline" /> {t['Create']()}{' '}
<div className={styles.spacer} /> <TempTagItem value={inputValue} color={nextColor} />
<EditTagMenu tagId={tag.id} onTagDelete={onTagDelete}> </div>
<IconButton );
className={styles.tagEditIcon} } else {
type="plain" return (
icon={<MoreHorizontalIcon />} <div
/> key={tag.id}
</EditTagMenu> {...commonProps}
</div> data-tag-id={tag.id}
); data-tag-value={tag.value$.value}
>
<TagItem maxWidth="100%" tag={tag} mode="inline" />
<div className={styles.spacer} />
<EditTagMenu tagId={tag.id} onTagDelete={onTagDelete}>
<IconButton
className={styles.tagEditIcon}
type="plain"
icon={<MoreHorizontalIcon />}
/>
</EditTagMenu>
</div>
);
}
})} })}
{match || !inputValue ? null : (
<div
data-testid="tag-selector-item"
className={styles.tagSelectorItem}
onClick={() => {
setInputValue('');
onCreateTag(inputValue);
}}
>
{t['Create']()}{' '}
<TempTagItem value={inputValue} color={nextColor} />
</div>
)}
</Scrollable.Viewport> </Scrollable.Viewport>
<Scrollable.Scrollbar style={{ transform: 'translateX(6px)' }} /> <Scrollable.Scrollbar style={{ transform: 'translateX(6px)' }} />
</Scrollable.Root> </Scrollable.Root>

View File

@ -78,6 +78,12 @@ export const tagInnerWrapper = style({
justifyContent: 'space-between', justifyContent: 'space-between',
padding: '0 8px', padding: '0 8px',
color: cssVar('textPrimaryColor'), color: cssVar('textPrimaryColor'),
borderColor: cssVar('borderColor'),
selectors: {
'&[data-focused=true]': {
borderColor: cssVar('primaryColor'),
},
},
}); });
export const tagInline = style([ export const tagInline = style([
tagInnerWrapper, tagInnerWrapper,
@ -85,7 +91,8 @@ export const tagInline = style([
fontSize: 'inherit', fontSize: 'inherit',
borderRadius: '10px', borderRadius: '10px',
columnGap: '4px', columnGap: '4px',
border: `1px solid ${cssVar('borderColor')}`, borderWidth: '1px',
borderStyle: 'solid',
background: cssVar('backgroundPrimaryColor'), background: cssVar('backgroundPrimaryColor'),
maxWidth: '128px', maxWidth: '128px',
height: '100%', height: '100%',

View File

@ -22,6 +22,7 @@ interface TagItemProps {
idx?: number; idx?: number;
maxWidth?: number | string; maxWidth?: number | string;
mode: 'inline' | 'list-item'; mode: 'inline' | 'list-item';
focused?: boolean;
onRemoved?: () => void; onRemoved?: () => void;
style?: React.CSSProperties; style?: React.CSSProperties;
} }
@ -54,6 +55,7 @@ export const TagItem = ({
tag, tag,
idx, idx,
mode, mode,
focused,
onRemoved, onRemoved,
style, style,
maxWidth, maxWidth,
@ -79,6 +81,7 @@ export const TagItem = ({
> >
<div <div
style={{ maxWidth: maxWidth }} style={{ maxWidth: maxWidth }}
data-focused={focused}
className={mode === 'inline' ? styles.tagInline : styles.tagListItem} className={mode === 'inline' ? styles.tagInline : styles.tagListItem}
> >
<div <div

View File

@ -63,12 +63,6 @@ export class TagList extends Entity {
return trimmedValue.includes(trimmedQuery); return trimmedValue.includes(trimmedQuery);
} }
private findfn(value: string, query?: string) {
const trimmedTagValue = query?.trim().toLowerCase();
const trimmedInputValue = value.trim().toLowerCase();
return trimmedTagValue === trimmedInputValue;
}
filterTagsByName$(name: string) { filterTagsByName$(name: string) {
return LiveData.computed(get => { return LiveData.computed(get => {
return get(this.tags$).filter(tag => return get(this.tags$).filter(tag =>
@ -76,16 +70,4 @@ export class TagList extends Entity {
); );
}); });
} }
tagByTagValue$(value: string) {
return LiveData.computed(get => {
return get(this.tags$).find(tag => this.filterFn(get(tag.value$), value));
});
}
excactTagByValue$(value: string) {
return LiveData.computed(get => {
return get(this.tags$).find(tag => this.findfn(get(tag.value$), value));
});
}
} }

View File

@ -45,6 +45,24 @@ test('allow create tag', async ({ page }) => {
await expectTagsVisible(page, ['Test2']); await expectTagsVisible(page, ['Test2']);
}); });
test('allow using keyboard to navigate tags', async ({ page }) => {
await openTagsEditor(page);
await searchAndCreateTag(page, 'Test1');
await searchAndCreateTag(page, 'Test2');
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('Backspace');
await closeTagsEditor(page);
await expectTagsVisible(page, ['Test1']);
await openTagsEditor(page);
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await closeTagsEditor(page);
await expectTagsVisible(page, ['Test1', 'Test2']);
});
test('allow create tag on journals page', async ({ page }) => { test('allow create tag on journals page', async ({ page }) => {
await openJournalsPage(page); await openJournalsPage(page);
await waitForEditorLoad(page); await waitForEditorLoad(page);