fix(core): at menu ux (#10485)

fix AF-2285

1. loading icon will be rendered to the group name
2. make the focused item more stable
This commit is contained in:
pengx17
2025-02-28 02:34:28 +00:00
parent 59526080d4
commit cd0bec5d31
6 changed files with 120 additions and 71 deletions

View File

@@ -55,12 +55,13 @@ export interface LinkedWidgetConfig {
* *
* If the return value is not null, no action will be taken. * If the return value is not null, no action will be taken.
*/ */
autoFocusedItem?: ( autoFocusedItemKey?: (
menus: LinkedMenuGroup[], menus: LinkedMenuGroup[],
query: string, query: string,
currentActiveKey: string | null,
editorHost: EditorHost, editorHost: EditorHost,
inlineEditor: AffineInlineEditor inlineEditor: AffineInlineEditor
) => LinkedMenuItem | null; ) => string | null;
mobile: { mobile: {
useScreenHeight?: boolean; useScreenHeight?: boolean;
@@ -101,8 +102,6 @@ export type LinkedMenuGroup = {
loading?: boolean | Signal<boolean>; loading?: boolean | Signal<boolean>;
// copywriting when display quantity exceeds // copywriting when display quantity exceeds
overflowText?: string | Signal<string>; overflowText?: string | Signal<string>;
// loading text
loadingText?: string | Signal<string>;
}; };
export type LinkedDocContext = { export type LinkedDocContext = {

View File

@@ -75,23 +75,33 @@ export class LinkedDocPopover extends SignalWatcher(
// need to rebind the effect because this._linkedDocGroup has changed. // need to rebind the effect because this._linkedDocGroup has changed.
this._menusItemsEffectCleanup = effect(() => { this._menusItemsEffectCleanup = effect(() => {
this._updateAutoFocusedItem(); this._updateAutoFocusedItem();
// wait for the next tick to ensure the items are rendered to DOM
setTimeout(() => {
this.scrollToFocusedItem();
});
}); });
}; };
private readonly _updateAutoFocusedItem = () => { private readonly _updateAutoFocusedItem = () => {
if (!this._query) { // Get the auto-focused item key from the config
return; const autoFocusedItemKey = this.context.config.autoFocusedItemKey?.(
}
const autoFocusedItem = this.context.config.autoFocusedItem?.(
this._linkedDocGroup, this._linkedDocGroup,
this._query, this._query || '',
this._activatedItemKey,
this.context.std.host, this.context.std.host,
this.context.inlineEditor this.context.inlineEditor
); );
if (autoFocusedItem) {
this._activatedItemIndex = this._flattenActionList.findIndex( if (autoFocusedItemKey) {
item => item.key === autoFocusedItem.key this._activatedItemKey = autoFocusedItemKey;
); return;
}
// If no auto-focused item key is returned from the config and no item is currently focused,
// focus the first item in the flattened action list
if (!this._activatedItemKey && this._flattenActionList.length > 0) {
this._activatedItemKey = this._flattenActionList[0].key;
} }
}; };
@@ -126,19 +136,9 @@ export class LinkedDocPopover extends SignalWatcher(
let items = resolveSignal(group.items); let items = resolveSignal(group.items);
const isOverflow = !!group.maxDisplay && items.length > group.maxDisplay; const isOverflow = !!group.maxDisplay && items.length > group.maxDisplay;
const isLoading = resolveSignal(group.loading);
items = isExpanded ? items : items.slice(0, group.maxDisplay); items = isExpanded ? items : items.slice(0, group.maxDisplay);
if (isLoading) {
items = items.concat({
key: 'loading',
name: resolveSignal(group.loadingText) || 'loading',
icon: LoadingIcon,
action: () => {},
});
}
if (isOverflow && !isExpanded && group.maxDisplay) { if (isOverflow && !isExpanded && group.maxDisplay) {
items = items.concat({ items = items.concat({
key: `${group.name} More`, key: `${group.name} More`,
@@ -183,7 +183,6 @@ export class LinkedDocPopover extends SignalWatcher(
target: eventSource, target: eventSource,
signal: keydownObserverAbortController.signal, signal: keydownObserverAbortController.signal,
onInput: isComposition => { onInput: isComposition => {
this._activatedItemIndex = 0;
if (isComposition) { if (isComposition) {
this._updateLinkedDocGroup().catch(console.error); this._updateLinkedDocGroup().catch(console.error);
} else { } else {
@@ -193,7 +192,6 @@ export class LinkedDocPopover extends SignalWatcher(
} }
}, },
onPaste: () => { onPaste: () => {
this._activatedItemIndex = 0;
setTimeout(() => { setTimeout(() => {
this._updateLinkedDocGroup().catch(console.error); this._updateLinkedDocGroup().catch(console.error);
}, 50); }, 50);
@@ -206,33 +204,18 @@ export class LinkedDocPopover extends SignalWatcher(
if (curRange.index < this.context.startRange.index) { if (curRange.index < this.context.startRange.index) {
this.context.close(); this.context.close();
} }
this._activatedItemIndex = 0;
this.context.inlineEditor.slots.renderComplete.once( this.context.inlineEditor.slots.renderComplete.once(
this._updateLinkedDocGroup this._updateLinkedDocGroup
); );
}, },
onMove: step => { onMove: step => {
const itemLen = this._flattenActionList.length; const itemLen = this._flattenActionList.length;
this._activatedItemIndex = const nextIndex = (itemLen + this._activatedItemIndex + step) % itemLen;
(itemLen + this._activatedItemIndex + step) % itemLen; const item = this._flattenActionList[nextIndex];
if (item) {
// Scroll to the active item this._activatedItemKey = item.key;
const item = this._flattenActionList[this._activatedItemIndex];
const shadowRoot = this.shadowRoot;
if (!shadowRoot) {
console.warn('Failed to find the shadow root!', this);
return;
} }
const ele = shadowRoot.querySelector( this.scrollToFocusedItem();
`icon-button[data-id="${item.key}"]`
);
if (!ele) {
console.warn('Failed to find the active item!', item);
return;
}
ele.scrollIntoView({
block: 'nearest',
});
}, },
onConfirm: () => { onConfirm: () => {
this._flattenActionList[this._activatedItemIndex] this._flattenActionList[this._activatedItemIndex]
@@ -261,19 +244,29 @@ export class LinkedDocPopover extends SignalWatcher(
visibility: 'hidden', visibility: 'hidden',
}); });
// XXX This is a side effect const actionGroups = this._actionGroup.map(group => {
let accIdx = 0; // Check if the group is loading
const isLoading = resolveSignal(group.loading);
return {
...group,
isLoading,
};
});
return html`<div class="linked-doc-popover" style="${style}"> return html`<div class="linked-doc-popover" style="${style}">
${this._actionGroup ${actionGroups
.filter(group => group.items.length) .filter(group => group.items.length || group.isLoading)
.map((group, idx) => { .map((group, idx) => {
return html` return html`
<div class="divider" ?hidden=${idx === 0}></div> <div class="divider" ?hidden=${idx === 0}></div>
<div class="group-title">${group.name}</div> <div class="group-title">
${group.name}
${group.isLoading
? html`<span class="loading-icon">${LoadingIcon}</span>`
: nothing}
</div>
<div class="group" style=${group.styles ?? ''}> <div class="group" style=${group.styles ?? ''}>
${group.items.map(({ key, name, icon, action }) => { ${group.items.map(({ key, name, icon, action }) => {
accIdx++;
const curIdx = accIdx - 1;
const tooltip = this._showTooltip const tooltip = this._showTooltip
? html`<affine-tooltip ? html`<affine-tooltip
tip-position=${'right'} tip-position=${'right'}
@@ -290,13 +283,13 @@ export class LinkedDocPopover extends SignalWatcher(
height="30px" height="30px"
data-id=${key} data-id=${key}
.text=${name} .text=${name}
hover=${this._activatedItemIndex === curIdx} hover=${this._activatedItemKey === key}
@click=${() => { @click=${() => {
action()?.catch(console.error); action()?.catch(console.error);
}} }}
@mousemove=${() => { @mousemove=${() => {
// Use `mousemove` instead of `mouseover` to avoid navigate conflict with keyboard // Use `mousemove` instead of `mouseover` to avoid navigate conflict with keyboard
this._activatedItemIndex = curIdx; this._activatedItemKey = key;
// show tooltip whether text length overflows // show tooltip whether text length overflows
for (const button of this.iconButtons.values()) { for (const button of this.iconButtons.values()) {
if (button.dataset.id == key && button.textElement) { if (button.dataset.id == key && button.textElement) {
@@ -348,8 +341,40 @@ export class LinkedDocPopover extends SignalWatcher(
} }
} }
private scrollToFocusedItem() {
const shadowRoot = this.shadowRoot;
if (!shadowRoot) {
return;
}
// If there's no active item key, don't try to scroll
if (!this._activatedItemKey) {
return;
}
const ele = shadowRoot.querySelector(
`icon-button[data-id="${this._activatedItemKey}"]`
);
// If the element doesn't exist, don't log a warning
if (!ele) {
return;
}
ele.scrollIntoView({
block: 'nearest',
});
}
get _activatedItemIndex() {
const index = this._flattenActionList.findIndex(
item => item.key === this._activatedItemKey
);
return index === -1 ? 0 : index;
}
@state() @state()
private accessor _activatedItemIndex = 0; private accessor _activatedItemKey: string | null = null;
@state() @state()
private accessor _linkedDocGroup: LinkedMenuGroup[] = []; private accessor _linkedDocGroup: LinkedMenuGroup[] = [];

View File

@@ -51,6 +51,18 @@ export const linkedDocPopoverStyles = css`
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
font-weight: 500; font-weight: 500;
justify-content: space-between;
}
.linked-doc-popover .group-title .loading-icon {
display: flex;
align-items: center;
margin-left: 8px;
}
.linked-doc-popover .group-title .loading-icon svg {
width: 20px;
height: 20px;
} }
.linked-doc-popover .divider { .linked-doc-popover .divider {

View File

@@ -32,6 +32,12 @@ function resolveSignal<T>(data: T | Signal<T>): T {
return data instanceof Signal ? data.value : data; return data instanceof Signal ? data.value : data;
} }
const RESERVED_ITEM_KEYS = {
createPage: 'create:page',
createEdgeless: 'create:edgeless',
datePicker: 'date-picker',
};
export class AtMenuConfigService extends Service { export class AtMenuConfigService extends Service {
constructor( constructor(
private readonly journalService: JournalService, private readonly journalService: JournalService,
@@ -50,7 +56,7 @@ export class AtMenuConfigService extends Service {
return { return {
getMenus: this.getMenusFn(), getMenus: this.getMenusFn(),
mobile: this.getMobileConfig(), mobile: this.getMobileConfig(),
autoFocusedItem: this.autoFocusedItem, autoFocusedItemKey: this.autoFocusedItemKey,
}; };
} }
@@ -61,18 +67,27 @@ export class AtMenuConfigService extends Service {
}); });
} }
private readonly autoFocusedItem = ( private readonly autoFocusedItemKey = (
menus: LinkedMenuGroup[], menus: LinkedMenuGroup[],
query: string query: string,
): LinkedMenuItem | null => { currentActiveKey: string | null
): string | null => {
if (query.trim().length === 0) { if (query.trim().length === 0) {
return null; return null;
} }
if (
currentActiveKey === RESERVED_ITEM_KEYS.createPage ||
currentActiveKey === RESERVED_ITEM_KEYS.createEdgeless
) {
return currentActiveKey;
}
// if the second group (linkToDocGroup) is EMPTY, // if the second group (linkToDocGroup) is EMPTY,
// if the query is NOT empty && the second group (linkToDocGroup) is EMPTY, // if the query is NOT empty && the second group (linkToDocGroup) is EMPTY,
// we will focus on the first item of the third group (create), which is the "New Doc" item. // we will focus on the first item of the third group (create), which is the "New Doc" item.
if (resolveSignal(menus[1].items).length === 0) { if (resolveSignal(menus[1].items).length === 0) {
return resolveSignal(menus[2].items)[0]; return resolveSignal(menus[2].items)[0]?.key;
} }
return null; return null;
}; };
@@ -95,11 +110,9 @@ export class AtMenuConfigService extends Service {
? originalNewDocMenuGroup.items ? originalNewDocMenuGroup.items
: originalNewDocMenuGroup.items.value; : originalNewDocMenuGroup.items.value;
const newDocItem = items.find(item => item.key === 'create');
const importItem = items.find(item => item.key === 'import'); const importItem = items.find(item => item.key === 'import');
// should have both new doc and import item if (!importItem) {
if (!newDocItem || !importItem) {
return originalNewDocMenuGroup; return originalNewDocMenuGroup;
} }
@@ -117,7 +130,7 @@ export class AtMenuConfigService extends Service {
const customNewDocItems: LinkedMenuItem[] = [ const customNewDocItems: LinkedMenuItem[] = [
{ {
key: 'create-page', key: RESERVED_ITEM_KEYS.createPage,
icon: NewXxxPageIcon(), icon: NewXxxPageIcon(),
name: I18n.t('com.affine.editor.at-menu.create-page', { name: I18n.t('com.affine.editor.at-menu.create-page', {
name: query || I18n.t('Untitled'), name: query || I18n.t('Untitled'),
@@ -132,7 +145,7 @@ export class AtMenuConfigService extends Service {
}, },
}, },
{ {
key: 'create-edgeless', key: RESERVED_ITEM_KEYS.createEdgeless,
icon: NewXxxEdgelessIcon(), icon: NewXxxEdgelessIcon(),
name: I18n.t('com.affine.editor.at-menu.create-edgeless', { name: I18n.t('com.affine.editor.at-menu.create-edgeless', {
name: query || I18n.t('Untitled'), name: query || I18n.t('Untitled'),
@@ -190,7 +203,7 @@ export class AtMenuConfigService extends Service {
const items: LinkedMenuItem[] = [ const items: LinkedMenuItem[] = [
{ {
icon: DateTimeIcon(), icon: DateTimeIcon(),
key: 'date-picker', key: RESERVED_ITEM_KEYS.datePicker,
name: I18n.t('com.affine.editor.at-menu.date-picker'), name: I18n.t('com.affine.editor.at-menu.date-picker'),
action: () => { action: () => {
close(); close();
@@ -245,7 +258,7 @@ export class AtMenuConfigService extends Service {
items.unshift({ items.unshift({
icon: icon(), icon: icon(),
key: dateString, key: RESERVED_ITEM_KEYS.datePicker + ':' + dateString,
name: alias name: alias
? html`${alias}, ? html`${alias},
<span style="color: ${cssVarV2('text/secondary')}" <span style="color: ${cssVarV2('text/secondary')}"

View File

@@ -11,7 +11,7 @@ import { Service } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2'; import { cssVarV2 } from '@toeverything/theme/v2';
import { html } from 'lit'; import { html } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { map } from 'rxjs'; import { map, takeWhile } from 'rxjs';
import type { DocDisplayMetaService } from '../../doc-display-meta'; import type { DocDisplayMetaService } from '../../doc-display-meta';
import type { DocsSearchService } from '../../docs-search'; import type { DocsSearchService } from '../../docs-search';
@@ -105,7 +105,8 @@ export class DocSearchMenuService extends Service {
const { signal: isIndexerLoading, cleanup: cleanupIndexerLoading } = const { signal: isIndexerLoading, cleanup: cleanupIndexerLoading } =
createSignalFromObservable( createSignalFromObservable(
this.docsSearch.indexer.status$.pipe( this.docsSearch.indexer.status$.pipe(
map(status => status.remaining !== undefined && status.remaining > 0) map(status => status.remaining !== undefined && status.remaining > 0),
takeWhile(isLoading => isLoading, true)
), ),
false false
); );
@@ -127,7 +128,6 @@ export class DocSearchMenuService extends Service {
query, query,
}), }),
loading: isIndexerLoading, loading: isIndexerLoading,
loadingText: I18n.t('com.affine.editor.at-menu.loading'),
items: docsSignal, items: docsSignal,
maxDisplay: MAX_DOCS, maxDisplay: MAX_DOCS,
overflowText, overflowText,

View File

@@ -483,7 +483,7 @@ test('@ popover can auto focus on the "New Doc" item when query returns no items
await expect(page.locator('.linked-doc-popover')).toBeVisible(); await expect(page.locator('.linked-doc-popover')).toBeVisible();
const newDocMenuItem = page const newDocMenuItem = page
.locator('.linked-doc-popover') .locator('.linked-doc-popover')
.locator('[data-id="create-page"]'); .locator('[data-id="create:page"]');
await expect(newDocMenuItem).toBeVisible(); await expect(newDocMenuItem).toBeVisible();
await expect(newDocMenuItem).toHaveAttribute('hover', 'true'); await expect(newDocMenuItem).toHaveAttribute('hover', 'true');
}); });