feat(mobile): replace search with create in app tab (#8934)

- Remove search on AppTab, replaced with `create doc`
- Always show AppTab for editor page
- Extract `NavigationBack` from `PageHeader`
This commit is contained in:
CatsJuice 2024-12-02 02:55:04 +00:00
parent 5e92d6cfe9
commit 6a71b28a61
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
10 changed files with 156 additions and 63 deletions

View File

@ -0,0 +1,51 @@
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
import { JournalService } from '@affine/core/modules/journal';
import { WorkbenchService } from '@affine/core/modules/workbench';
import track from '@affine/track';
import { EditIcon } from '@blocksuite/icons/rc';
import {
DocsService,
useLiveData,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import { useCallback } from 'react';
import { tabItem } from './styles.css';
export const AppTabCreate = () => {
const { docsService, workbenchService, workspaceService, journalService } =
useServices({
DocsService,
WorkbenchService,
WorkspaceService,
JournalService,
});
const workbench = workbenchService.workbench;
const currentWorkspace = workspaceService.workspace;
const location = useLiveData(workbench.location$);
const pageHelper = usePageHelper(currentWorkspace.docCollection);
const maybeDocId = location.pathname.split('/')[1].split('?')[0];
const doc = useLiveData(docsService.list.doc$(maybeDocId));
const journalDate = useLiveData(journalService.journalDate$(maybeDocId));
const isActive = !!doc && !journalDate;
const createPage = useCallback(() => {
if (isActive) return;
pageHelper.createPage(undefined, true);
track.$.navigationPanel.$.createDoc();
}, [isActive, pageHelper]);
return (
<div
className={tabItem}
data-active={isActive}
role="tab"
aria-label="New Page"
onClick={createPage}
>
<EditIcon />
</div>
);
};

View File

@ -3,13 +3,14 @@ import {
WorkbenchLink,
WorkbenchService,
} from '@affine/core/modules/workbench';
import { AllDocsIcon, MobileHomeIcon, SearchIcon } from '@blocksuite/icons/rc';
import { AllDocsIcon, MobileHomeIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import React from 'react';
import type { Location } from 'react-router-dom';
import { VirtualKeyboardService } from '../../modules/virtual-keyboard/services/virtual-keyboard';
import { AppTabCreate } from './create';
import { AppTabJournal } from './journal';
import * as styles from './styles.css';
@ -48,9 +49,8 @@ const routes: Route[] = [
node: <AppTabJournal />,
},
{
key: 'search',
to: '/search',
Icon: SearchIcon,
key: 'new',
node: <AppTabCreate />,
},
];

View File

@ -1,5 +1,6 @@
export * from './app-tabs';
export * from './doc-card';
export * from './navigation-back';
export * from './page-header';
export * from './rename';
export * from './search-input';

View File

@ -0,0 +1,54 @@
import {
IconButton,
type IconButtonProps,
useIsInsideModal,
} from '@affine/component';
import { ArrowLeftSmallIcon, CloseIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { useCallback, useEffect, useMemo } from 'react';
import { NavigationGestureService } from '../../modules/navigation-gesture';
export interface NavigationBackButtonProps extends IconButtonProps {
backAction?: () => void;
}
/**
* A button to control the back behavior of the mobile app, as well as manage navigation gesture
*/
export const NavigationBackButton = ({
backAction,
style: propsStyle,
...otherProps
}: NavigationBackButtonProps) => {
const navigationGesture = useService(NavigationGestureService);
const isInsideModal = useIsInsideModal();
const handleRouteBack = useCallback(() => {
backAction ? backAction() : history.back();
}, [backAction]);
useEffect(() => {
if (isInsideModal) return;
const prev = navigationGesture.enabled$.value;
navigationGesture.setEnabled(true);
return () => {
navigationGesture.setEnabled(prev);
};
}, [isInsideModal, navigationGesture]);
const style = useMemo(() => ({ padding: 10, ...propsStyle }), [propsStyle]);
return (
<IconButton
size={24}
style={style}
onClick={handleRouteBack}
icon={isInsideModal ? <CloseIcon /> : <ArrowLeftSmallIcon />}
data-testid="page-header-back"
{...otherProps}
/>
);
};

View File

@ -1,16 +1,8 @@
import { IconButton, SafeArea, useIsInsideModal } from '@affine/component';
import { ArrowLeftSmallIcon, CloseIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { SafeArea } from '@affine/component';
import clsx from 'clsx';
import {
forwardRef,
type HtmlHTMLAttributes,
type ReactNode,
useCallback,
useEffect,
} from 'react';
import { forwardRef, type HtmlHTMLAttributes, type ReactNode } from 'react';
import { NavigationGestureService } from '../../modules/navigation-gesture';
import { NavigationBackButton } from '../navigation-back';
import * as styles from './styles.css';
export interface PageHeaderProps
@ -74,24 +66,6 @@ export const PageHeader = forwardRef<HTMLDivElement, PageHeaderProps>(
},
ref
) {
const navigationGesture = useService(NavigationGestureService);
const isInsideModal = useIsInsideModal();
useEffect(() => {
if (isInsideModal) return;
const prev = navigationGesture.enabled$.value;
navigationGesture.setEnabled(!!back);
return () => {
navigationGesture.setEnabled(prev);
};
}, [back, isInsideModal, navigationGesture]);
const handleRouteBack = useCallback(() => {
backAction ? backAction() : history.back();
}, [backAction]);
return (
<>
<SafeArea
@ -106,15 +80,7 @@ export const PageHeader = forwardRef<HTMLDivElement, PageHeaderProps>(
className={clsx(styles.prefix, prefixClassName)}
style={prefixStyle}
>
{back ? (
<IconButton
size={24}
style={{ padding: 10 }}
onClick={handleRouteBack}
icon={isInsideModal ? <CloseIcon /> : <ArrowLeftSmallIcon />}
data-testid="page-header-back"
/>
) : null}
{back ? <NavigationBackButton backAction={backAction} /> : null}
{prefix}
</section>

View File

@ -169,7 +169,7 @@ const DetailPageImpl = () => {
editor.bindEditorContainer(
editorContainer,
null,
(editorContainer as any).docTitle, // set from proxy
scrollViewportRef.current
);
@ -272,9 +272,7 @@ const MobileDetailPage = ({
) : null}
</PageHeader>
<DetailPageImpl />
{date ? (
<AppTabs background={cssVarV2('layer/background/primary')} />
) : null}
<AppTabs background={cssVarV2('layer/background/primary')} />
</DetailPageWrapper>
</div>
);

View File

@ -1,10 +1,15 @@
import { SafeArea, useThemeColorV2 } from '@affine/component';
import {
SafeArea,
startScopedViewTransition,
useThemeColorV2,
} from '@affine/component';
import { CollectionService } from '@affine/core/modules/collection';
import {
type QuickSearchItem,
QuickSearchTagIcon,
} from '@affine/core/modules/quicksearch';
import { TagService } from '@affine/core/modules/tag';
import { sleep } from '@blocksuite/affine/global/utils';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import {
LiveData,
@ -14,7 +19,12 @@ import {
} from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { AppTabs, SearchInput, SearchResLabel } from '../../components';
import {
NavigationBackButton,
SearchInput,
SearchResLabel,
} from '../../components';
import { searchVTScope } from '../../components/search-input/style.css';
import { MobileSearchService } from '../../modules/search';
import { SearchResults } from '../../views/search/search-results';
import * as styles from '../../views/search/style.css';
@ -133,11 +143,20 @@ export const Component = () => {
]
);
const transitionBack = useCallback(() => {
startScopedViewTransition(searchVTScope, async () => {
history.back();
await sleep(10);
});
}, []);
return (
<>
<SafeArea top>
<div className={styles.searchHeader} data-testid="search-header">
<NavigationBackButton backAction={transitionBack} />
<SearchInput
className={styles.searchInput}
debounce={300}
autoFocus={!searchInput}
value={searchInput}
@ -147,7 +166,6 @@ export const Component = () => {
</div>
</SafeArea>
{searchInput ? <WithQueryList /> : <RecentList />}
<AppTabs />
</>
);
};

View File

@ -4,6 +4,14 @@ import { style } from '@vanilla-extract/css';
export const searchHeader = style({
padding: 16,
paddingLeft: 8,
display: 'flex',
alignItems: 'center',
gap: 4,
});
export const searchInput = style({
width: 0,
flex: 1,
});
export const resTitle = style([

View File

@ -229,7 +229,15 @@ export class Editor extends Entity {
const title = docTitle?.querySelector<
HTMLElement & { inlineEditor: InlineEditor | null }
>('rich-text');
title?.inlineEditor?.focusEnd();
// Only focus on the title when it's empty on mobile edition.
if (BUILD_CONFIG.isMobileEdition) {
const titleText = this.doc.title$.value;
if (!titleText?.length) {
title?.inlineEditor?.focusEnd();
}
} else {
title?.inlineEditor?.focusEnd();
}
} else {
const selection = editorContainer.host?.std.selection;

View File

@ -13,7 +13,8 @@ test('app tabs is visible', async ({ page }) => {
await expect(tabs.getByRole('tab', { name: 'home' })).toBeVisible();
await expect(tabs.getByRole('tab', { name: 'all' })).toBeVisible();
await expect(tabs.getByRole('tab', { name: 'search' })).toBeVisible();
await expect(tabs.getByRole('tab', { name: 'journal' })).toBeVisible();
await expect(tabs.getByRole('tab', { name: 'new' })).toBeVisible();
});
test('recent docs', async ({ page }) => {
@ -49,15 +50,3 @@ test('all tab', async ({ page }) => {
const todayDocs = page.getByTestId('doc-card');
expect(await todayDocs.count()).toBeGreaterThan(0);
});
test('search tab', async ({ page }) => {
const searchTab = page
.locator('#app-tabs')
.getByRole('tab', { name: 'search' });
await expect(searchTab).toBeVisible();
await searchTab.click();
const searchInput = page.getByTestId('search-header').getByRole('textbox');
await expect(searchInput).toBeVisible();
});