diff --git a/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.css.ts b/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.css.ts index 3e9d13b57b..7187c49b1b 100644 --- a/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.css.ts +++ b/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.css.ts @@ -90,8 +90,10 @@ export const tagSelectorItem = style({ gap: 8, cursor: 'pointer', borderRadius: '4px', - ':hover': { - backgroundColor: cssVar('hoverColor'), + selectors: { + '&[data-focused=true]': { + backgroundColor: cssVar('hoverColor'), + }, }, }); diff --git a/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.tsx b/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.tsx index 62357b4825..cf28fe666d 100644 --- a/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/tags-inline-editor.tsx @@ -2,11 +2,13 @@ import type { MenuProps } from '@affine/component'; import { IconButton, Input, Menu, Scrollable } from '@affine/component'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; import { WorkspaceLegacyProperties } from '@affine/core/modules/properties'; +import type { Tag } from '@affine/core/modules/tag'; import { DeleteTagConfirmModal, TagService } from '@affine/core/modules/tag'; import { useI18n } from '@affine/i18n'; import { DeleteIcon, MoreHorizontalIcon, TagsIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; import clsx from 'clsx'; +import { clamp } from 'lodash-es'; import type { HTMLAttributes, PropsWithChildren } from 'react'; import { useCallback, useMemo, useReducer, useRef, useState } from 'react'; @@ -19,6 +21,7 @@ import * as styles from './tags-inline-editor.css'; interface TagsEditorProps { pageId: string; readonly?: boolean; + focusedIndex?: number; } interface InlineTagsListProps @@ -31,6 +34,7 @@ const InlineTagsList = ({ pageId, readonly, children, + focusedIndex, onRemove, }: PropsWithChildren) => { const tagList = useService(TagService).tagList; @@ -54,6 +58,7 @@ const InlineTagsList = ({ {children}; }; +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) => { const t = useI18n(); const tagList = useService(TagService).tagList; const tags = useLiveData(tagList.tags$); const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId)); const [inputValue, setInputValue] = useState(''); + const filteredTags = useLiveData( + inputValue ? tagList.filterTagsByName$(inputValue) : tagList.tags$ + ); const [open, setOpen] = useState(false); const [selectedTagIds, setSelectedTagIds] = useState([]); const inputRef = useRef(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(-1); + const [focusedInlineIndex, setFocusedInlineIndex] = useState( + 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(null); + const handleCloseModal = useCallback( (open: boolean) => { setOpen(open); @@ -197,13 +237,6 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => { [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( (e: React.ChangeEvent) => { setInputValue(e.target.value); @@ -211,13 +244,19 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => { [] ); - const onAddTag = useCallback( + const onToggleTag = useCallback( (id: string) => { + const tagEntity = tagList.tags$.value.find(o => o.id === id); + if (!tagEntity) { + return; + } 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(() => { @@ -234,40 +273,76 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => { const onCreateTag = useCallback( (name: string) => { - if (!name.trim()) { - return; - } rotateNextColor(); const newTag = tagList.createTag(name.trim(), nextColor); - newTag.tag(pageId); + return newTag.id; }, - [nextColor, pageId, tagList] + [nextColor, tagList] ); - const onSelectTag = useCallback( - (id: string) => { - onAddTag(id); + const onSelectTagOption = useCallback( + (tagOption: TagOption) => { + const id = isCreateNewTag(tagOption) + ? onCreateTag(tagOption.value) + : tagOption.id; + onToggleTag(id); setInputValue(''); focusInput(); + setFocusedIndex(-1); + setFocusedInlineIndex(tagIds.length + 1); }, - [focusInput, onAddTag] + [onCreateTag, onToggleTag, focusInput, tagIds.length] ); const onInputKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter') { - if (exactMatch) { - onAddTag(exactMatch.id); - } else { - onCreateTag(inputValue); + if (safeFocusedIndex >= 0) { + onSelectTagOption(tagOptions[safeFocusedIndex]); } - setInputValue(''); } else if (e.key === 'Backspace' && inputValue === '' && tagIds.length) { - const lastTagId = tagIds[tagIds.length - 1]; - tags.find(tag => tag.id === lastTagId)?.untag(pageId); + const tagToRemove = + 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 ( @@ -276,6 +351,7 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => { { - {filteredTags.map(tag => { - return ( -
{ - onSelectTag(tag.id); - }} - > - -
- - } - /> - -
- ); + {tagOptions.map((tag, idx) => { + const commonProps = { + focused: safeFocusedIndex === idx, + onClick: () => onSelectTagOption(tag), + onMouseEnter: () => setFocusedIndex(idx), + ['data-testid']: 'tag-selector-item', + ['data-focused']: safeFocusedIndex === idx, + className: styles.tagSelectorItem, + }; + if (isCreateNewTag(tag)) { + return ( +
+ {t['Create']()}{' '} + +
+ ); + } else { + return ( +
+ +
+ + } + /> + +
+ ); + } })} - {match || !inputValue ? null : ( -
{ - setInputValue(''); - onCreateTag(inputValue); - }} - > - {t['Create']()}{' '} - -
- )} diff --git a/packages/frontend/core/src/components/page-list/docs/page-tags.css.ts b/packages/frontend/core/src/components/page-list/docs/page-tags.css.ts index f5bea7ae0f..e4487e0e30 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-tags.css.ts +++ b/packages/frontend/core/src/components/page-list/docs/page-tags.css.ts @@ -78,6 +78,12 @@ export const tagInnerWrapper = style({ justifyContent: 'space-between', padding: '0 8px', color: cssVar('textPrimaryColor'), + borderColor: cssVar('borderColor'), + selectors: { + '&[data-focused=true]': { + borderColor: cssVar('primaryColor'), + }, + }, }); export const tagInline = style([ tagInnerWrapper, @@ -85,7 +91,8 @@ export const tagInline = style([ fontSize: 'inherit', borderRadius: '10px', columnGap: '4px', - border: `1px solid ${cssVar('borderColor')}`, + borderWidth: '1px', + borderStyle: 'solid', background: cssVar('backgroundPrimaryColor'), maxWidth: '128px', height: '100%', diff --git a/packages/frontend/core/src/components/page-list/docs/page-tags.tsx b/packages/frontend/core/src/components/page-list/docs/page-tags.tsx index 2cbd7eb896..fd4a1d347f 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-tags.tsx +++ b/packages/frontend/core/src/components/page-list/docs/page-tags.tsx @@ -22,6 +22,7 @@ interface TagItemProps { idx?: number; maxWidth?: number | string; mode: 'inline' | 'list-item'; + focused?: boolean; onRemoved?: () => void; style?: React.CSSProperties; } @@ -54,6 +55,7 @@ export const TagItem = ({ tag, idx, mode, + focused, onRemoved, style, maxWidth, @@ -79,6 +81,7 @@ export const TagItem = ({ >
{ 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)); - }); - } } diff --git a/tests/affine-local/e2e/page-properties.spec.ts b/tests/affine-local/e2e/page-properties.spec.ts index 3639e69ad3..d96fb3cbcb 100644 --- a/tests/affine-local/e2e/page-properties.spec.ts +++ b/tests/affine-local/e2e/page-properties.spec.ts @@ -45,6 +45,24 @@ test('allow create tag', async ({ page }) => { 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 }) => { await openJournalsPage(page); await waitForEditorLoad(page);