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:
@@ -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 = {
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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')}"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user