feat(mobile): pwa and browser theme-color optimization (#8168)

[AF-1325](https://linear.app/affine-design/issue/AF-1325/优化-pwa-体验), [AF-1317](https://linear.app/affine-design/issue/AF-1317/优化:-pwa-的顶部-status-bar-颜色应与背景保持一致), [AF-1318](https://linear.app/affine-design/issue/AF-1318/优化:pwa-的底部应当有符合设备安全高度的padding), [AF-1321](https://linear.app/affine-design/issue/AF-1321/更新一下-fail-的-pwa-icon)

- New `<SafeArea />` ui component
- New `useThemeColorV1` / `useThemeColorV2` hook:
    - to modify `<meta name="theme-color" />` with given theme key
This commit is contained in:
CatsJuice
2024-09-11 02:20:59 +00:00
parent 9038592715
commit 81ab8ac8b3
31 changed files with 329 additions and 133 deletions

View File

@@ -1,2 +1,4 @@
export { useAutoFocus, useAutoSelect } from './focus-and-select';
export { useRefEffect } from './use-ref-effect';
export * from './use-theme-color-meta';
export * from './use-theme-value';

View File

@@ -0,0 +1,45 @@
import { useLayoutEffect } from 'react';
import { useThemeValueV1, useThemeValueV2 } from './use-theme-value';
let meta: HTMLMetaElement | null = null;
function getMeta() {
if (meta) return meta;
const exists = document.querySelector('meta[name="theme-color"]');
if (exists) {
meta = exists as HTMLMetaElement;
return meta;
}
// create and append meta
meta = document.createElement('meta');
meta.name = 'theme-color';
document.head.append(meta);
return meta;
}
export const useThemeColorMeta = (color: string) => {
useLayoutEffect(() => {
const meta = getMeta();
const old = meta.content;
meta.content = color;
return () => {
meta.content = old;
};
}, [color]);
};
export const useThemeColorV1 = (
...args: Parameters<typeof useThemeValueV1>
) => {
useThemeColorMeta(useThemeValueV1(...args));
};
export const useThemeColorV2 = (
...args: Parameters<typeof useThemeValueV2>
) => {
useThemeColorMeta(useThemeValueV2(...args));
};

View File

@@ -0,0 +1,19 @@
import { type AffineTheme, darkTheme, lightTheme } from '@toeverything/theme';
import {
type AffineThemeKeyV2,
darkThemeV2,
lightThemeV2,
} from '@toeverything/theme/v2';
import { useTheme } from 'next-themes';
export const useThemeValueV2 = (key: AffineThemeKeyV2) => {
const { resolvedTheme } = useTheme();
return resolvedTheme === 'dark' ? darkThemeV2[key] : lightThemeV2[key];
};
export const useThemeValueV1 = (key: keyof Omit<AffineTheme, 'editorMode'>) => {
const { resolvedTheme } = useTheme();
return resolvedTheme === 'dark' ? darkTheme[key] : lightTheme[key];
};

View File

@@ -21,6 +21,7 @@ export * from './ui/modal';
export * from './ui/notification';
export * from './ui/popover';
export * from './ui/radio';
export * from './ui/safe-area';
export * from './ui/scrollbar';
export * from './ui/skeleton';
export * from './ui/slider';

View File

@@ -111,10 +111,6 @@ export const MobileMenu = ({
className: clsx(className, styles.mobileMenuModal),
...otherContentOptions,
}}
contentWrapperStyle={{
alignItems: 'end',
paddingBottom: 10,
}}
>
<div
ref={setSliderElement}

View File

@@ -15,6 +15,7 @@ import { forwardRef, useCallback, useEffect, useState } from 'react';
import { startScopedViewTransition } from '../../utils';
import type { IconButtonProps } from '../button';
import { IconButton } from '../button';
import { SafeArea } from '../safe-area';
import * as styles from './styles.css';
export interface ModalProps extends DialogProps {
@@ -214,7 +215,9 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
}}
{...otherOverlayOptions}
>
<div
<SafeArea
bottom={environment.isMobileEdition}
bottomOffset={12}
data-full-screen={fullScreen}
data-modal={modal}
className={clsx(
@@ -278,7 +281,7 @@ export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
{children}
</Dialog.Content>
</div>
</SafeArea>
</Dialog.Overlay>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -83,7 +83,7 @@ export const modalContentWrapper = style({
'screen and (width <= 640px)': {
// todo: adjust animation
alignItems: 'flex-end',
paddingBottom: 32,
paddingBottom: 'env(safe-area-inset-bottom, 20px)',
},
},

View File

@@ -0,0 +1,49 @@
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { forwardRef, type HTMLAttributes } from 'react';
import { withUnit } from '../../utils/with-unit';
import { bottomOffsetVar, safeArea, topOffsetVar } from './style.css';
interface SafeAreaProps extends HTMLAttributes<HTMLDivElement> {
top?: boolean;
bottom?: boolean;
topOffset?: number | string;
bottomOffset?: number | string;
}
export const SafeArea = forwardRef<HTMLDivElement, SafeAreaProps>(
function SafeArea(
{
children,
className,
style,
top,
bottom,
topOffset = 0,
bottomOffset = 0,
...attrs
},
ref
) {
return (
<div
ref={ref}
className={clsx(safeArea, className)}
data-standalone={environment.isStandalone ? '' : undefined}
data-bottom={bottom ? '' : undefined}
data-top={top ? '' : undefined}
style={{
...style,
...assignInlineVars({
[topOffsetVar]: withUnit(topOffset, 'px'),
[bottomOffsetVar]: withUnit(bottomOffset, 'px'),
}),
}}
{...attrs}
>
{children}
</div>
);
}
);

View File

@@ -0,0 +1,22 @@
import { createVar, style } from '@vanilla-extract/css';
export const topOffsetVar = createVar();
export const bottomOffsetVar = createVar();
export const safeArea = style({
selectors: {
'&[data-top]': {
paddingTop: `calc(${topOffsetVar} + 12px)`,
},
'&[data-bottom]': {
paddingBottom: `calc(${bottomOffsetVar} + 0px)`,
},
'&[data-standalone][data-top]': {
paddingTop: `calc(env(safe-area-inset-top, 12px) + ${topOffsetVar})`,
},
'&[data-standalone][data-bottom]': {
// paddingBottom: 'env(safe-area-inset-bottom, 12px)',
paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${bottomOffsetVar})`,
},
},
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1,3 +1,4 @@
import { SafeArea } from '@affine/component';
import {
WorkbenchLink,
WorkbenchService,
@@ -39,29 +40,31 @@ export const AppTabs = () => {
const location = useLiveData(workbench.location$);
return (
<ul className={styles.appTabs} id="app-tabs" role="tablist">
{routes.map(route => {
const Link = route.LinkComponent || WorkbenchLink;
<SafeArea bottom className={styles.appTabs} bottomOffset={2}>
<ul className={styles.appTabsInner} id="app-tabs" role="tablist">
{routes.map(route => {
const Link = route.LinkComponent || WorkbenchLink;
const isActive = route.isActive
? route.isActive(location)
: location.pathname === route.to;
return (
<Link
data-active={isActive}
to={route.to}
key={route.to}
className={styles.tabItem}
role="tab"
aria-label={route.to.slice(1)}
replaceHistory
>
<li>
<route.Icon />
</li>
</Link>
);
})}
</ul>
const isActive = route.isActive
? route.isActive(location)
: location.pathname === route.to;
return (
<Link
data-active={isActive}
to={route.to}
key={route.to}
className={styles.tabItem}
role="tab"
aria-label={route.to.slice(1)}
replaceHistory
>
<li style={{ lineHeight: 0 }}>
<route.Icon />
</li>
</Link>
);
})}
</ul>
</SafeArea>
);
};

View File

@@ -4,23 +4,24 @@ import { style } from '@vanilla-extract/css';
import { globalVars } from '../../styles/mobile.css';
export const appTabs = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: cssVarV2('layer/background/secondary'),
borderTop: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
width: '100dvw',
height: `calc(${globalVars.appTabHeight} + 2px)`,
padding: 16,
gap: 15.5,
position: 'fixed',
paddingBottom: 18,
bottom: -2,
zIndex: 1,
});
export const appTabsInner = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 15.5,
height: `calc(${globalVars.appTabHeight} + 2px)`,
padding: 16,
});
export const tabItem = style({
display: 'flex',
alignItems: 'center',

View File

@@ -1,4 +1,4 @@
import { IconButton } from '@affine/component';
import { IconButton, SafeArea } from '@affine/component';
import { ArrowLeftSmallIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import {
@@ -42,7 +42,7 @@ export interface PageHeaderProps
suffixClassName?: string;
suffixStyle?: React.CSSProperties;
}
export const PageHeader = forwardRef<HTMLHeadElement, PageHeaderProps>(
export const PageHeader = forwardRef<HTMLDivElement, PageHeaderProps>(
function PageHeader(
{
back,
@@ -65,38 +65,41 @@ export const PageHeader = forwardRef<HTMLHeadElement, PageHeaderProps>(
}, [backAction]);
return (
<header
data-testid="mobile-page-header"
<SafeArea
top
ref={ref}
className={clsx(styles.root, className)}
data-testid="mobile-page-header"
{...attrs}
>
<section
className={clsx(styles.prefix, prefixClassName)}
style={prefixStyle}
>
{back ? (
<IconButton
size={24}
style={{ padding: 10 }}
onClick={handleRouteBack}
icon={<ArrowLeftSmallIcon />}
/>
) : null}
{prefix}
</section>
<header className={styles.inner}>
<section
className={clsx(styles.prefix, prefixClassName)}
style={prefixStyle}
>
{back ? (
<IconButton
size={24}
style={{ padding: 10 }}
onClick={handleRouteBack}
icon={<ArrowLeftSmallIcon />}
/>
) : null}
{prefix}
</section>
<section className={clsx(styles.content, { center: centerContent })}>
{children}
</section>
<section className={clsx(styles.content, { center: centerContent })}>
{children}
</section>
<section
className={clsx(styles.suffix, suffixClassName)}
style={suffixStyle}
>
{suffix}
</section>
</header>
<section
className={clsx(styles.suffix, suffixClassName)}
style={suffixStyle}
>
{suffix}
</section>
</header>
</SafeArea>
);
}
);

View File

@@ -3,18 +3,18 @@ import { style } from '@vanilla-extract/css';
export const root = style({
width: '100%',
minHeight: 44,
padding: '0 6px',
paddingTop: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
position: 'sticky',
top: 0,
zIndex: 1,
backgroundColor: cssVarV2('layer/background/secondary'),
});
export const inner = style({
minHeight: 44,
padding: '0 6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
});
export const content = style({
selectors: {
'&.center': {

View File

@@ -1,11 +1,17 @@
import { SafeArea, useThemeColorV2 } from '@affine/component';
import { AppTabs } from '../../components';
import { AllDocList, AllDocsHeader, AllDocsMenu } from '../../views';
export const Component = () => {
useThemeColorV2('layer/background/secondary');
return (
<>
<AllDocsHeader operations={<AllDocsMenu />} />
<AllDocList />
<SafeArea bottom>
<AllDocList />
</SafeArea>
<AppTabs />
</>
);

View File

@@ -1,4 +1,4 @@
import { notify } from '@affine/component';
import { notify, useThemeColorV2 } from '@affine/component';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { CollectionService } from '@affine/core/modules/collection';
import { isEmptyCollection } from '@affine/core/pages/workspace/collection';
@@ -16,6 +16,7 @@ import { AppTabs } from '../../../components';
import { CollectionDetail, EmptyCollection } from '../../../views';
export const Component = () => {
useThemeColorV2('layer/background/secondary');
const { collectionService, globalContextService, workspaceService } =
useServices({
WorkspaceService,

View File

@@ -1,7 +1,10 @@
import { useThemeColorV2 } from '@affine/component';
import { AppTabs } from '../../../components';
import { AllDocsHeader, CollectionList } from '../../../views';
export const Component = () => {
useThemeColorV2('layer/background/secondary');
return (
<>
<AllDocsHeader />

View File

@@ -1,3 +1,4 @@
import { useThemeColorV2 } from '@affine/component';
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
@@ -213,6 +214,7 @@ const notFound = (
);
export const Component = () => {
useThemeColorV2('layer/background/primary');
const params = useParams();
const pageId = params.pageId;

View File

@@ -1,3 +1,4 @@
import { SafeArea, useThemeColorV2 } from '@affine/component';
import {
ExplorerCollections,
ExplorerFavorites,
@@ -11,24 +12,28 @@ import { AppTabs } from '../../components';
import { HomeHeader, RecentDocs } from '../../views';
export const Component = () => {
useThemeColorV2('layer/background/secondary');
return (
<ExplorerMobileContext.Provider value={true}>
<HomeHeader />
<RecentDocs />
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 32,
padding: '0 8px 32px 8px',
}}
>
<ExplorerFavorites />
{runtimeConfig.enableOrganize && <ExplorerOrganize />}
<ExplorerMigrationFavorites />
<ExplorerCollections />
<ExplorerTags />
</div>
<SafeArea bottom>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 32,
padding: '0 8px 32px 8px',
}}
>
<ExplorerFavorites />
{runtimeConfig.enableOrganize && <ExplorerOrganize />}
<ExplorerMigrationFavorites />
<ExplorerCollections />
<ExplorerTags />
</div>
</SafeArea>
<AppTabs />
</ExplorerMobileContext.Provider>
);

View File

@@ -1,3 +1,4 @@
import { SafeArea, useThemeColorV2 } from '@affine/component';
import { CollectionService } from '@affine/core/modules/collection';
import {
type QuickSearchItem,
@@ -112,6 +113,7 @@ const WithQueryList = () => {
};
export const Component = () => {
useThemeColorV2('layer/background/secondary');
const searchInput = useLiveData(searchInput$);
const searchService = useService(MobileSearchService);
@@ -133,15 +135,17 @@ export const Component = () => {
return (
<>
<div className={styles.searchHeader} data-testid="search-header">
<SearchInput
debounce={300}
autoFocus={!searchInput}
value={searchInput}
onInput={onSearch}
placeholder="Search Docs, Collections"
/>
</div>
<SafeArea top>
<div className={styles.searchHeader} data-testid="search-header">
<SearchInput
debounce={300}
autoFocus={!searchInput}
value={searchInput}
onInput={onSearch}
placeholder="Search Docs, Collections"
/>
</div>
</SafeArea>
{searchInput ? <WithQueryList /> : <RecentList />}
<AppTabs />
</>

View File

@@ -1,3 +1,4 @@
import { useThemeColorV2 } from '@affine/component';
import { TagService } from '@affine/core/modules/tag';
import { PageNotFound } from '@affine/core/pages/404';
import {
@@ -11,6 +12,7 @@ import { useParams } from 'react-router-dom';
import { TagDetail } from '../../../views';
export const Component = () => {
useThemeColorV2('layer/background/secondary');
const params = useParams();
const tagId = params.tagId;

View File

@@ -1,7 +1,10 @@
import { useThemeColorV2 } from '@affine/component';
import { AppTabs } from '../../../components';
import { AllDocsHeader, TagList } from '../../../views';
export const Component = () => {
useThemeColorV2('layer/background/secondary');
return (
<>
<AllDocsHeader />

View File

@@ -13,6 +13,7 @@ globalStyle(':root', {
globalStyle('body', {
height: 'auto',
minHeight: '100dvh',
});
globalStyle('body:has(#app-tabs)', {
paddingBottom: globalVars.appTabHeight,
@@ -21,6 +22,3 @@ globalStyle('html', {
overflowY: 'auto',
background: cssVarV2('layer/background/secondary'),
});
globalStyle('body[data-scroll-locked][style]', {
overflow: 'clip !important',
});

View File

@@ -1,7 +1,7 @@
import { IconButton, MobileMenu } from '@affine/component';
import { IconButton, MobileMenu, SafeArea } from '@affine/component';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { header, headerSpace } from './style.css';
import { header, headerContent, headerSpace } from './style.css';
import { AllDocsTabs } from './tabs';
export interface AllDocsHeaderProps {
@@ -11,17 +11,21 @@ export interface AllDocsHeaderProps {
export const AllDocsHeader = ({ operations }: AllDocsHeaderProps) => {
return (
<>
<header className={header}>
<AllDocsTabs />
<div>
{operations ? (
<MobileMenu items={operations}>
<IconButton icon={<MoreHorizontalIcon />} />
</MobileMenu>
) : null}
</div>
</header>
<div className={headerSpace} />
<SafeArea top className={header}>
<header className={headerContent}>
<AllDocsTabs />
<div>
{operations ? (
<MobileMenu items={operations}>
<IconButton icon={<MoreHorizontalIcon />} />
</MobileMenu>
) : null}
</div>
</header>
</SafeArea>
<SafeArea top>
<div className={headerSpace} />
</SafeArea>
</>
);
};

View File

@@ -1,32 +1,31 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
const headerContentHeight = 56;
const headerPaddingTop = 16;
const basicHeader = style({
width: '100%',
height: headerContentHeight + headerPaddingTop,
height: 56,
});
export const header = style([
export const header = style({
width: '100%',
position: 'fixed',
top: 0,
backgroundColor: cssVarV2('layer/background/secondary'),
zIndex: 1,
});
export const headerSpace = style([basicHeader]);
export const headerContent = style([
basicHeader,
{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: `${headerPaddingTop}px 16px 0px 16px`,
position: 'fixed',
top: 0,
backgroundColor: cssVarV2('layer/background/secondary'),
zIndex: 1,
padding: `0px 16px`,
},
]);
export const headerSpace = style([basicHeader]);
export const tabs = style({
height: headerContentHeight,
height: 56,
gap: 16,
display: 'flex',
alignItems: 'center',

View File

@@ -1,4 +1,8 @@
import { IconButton, startScopedViewTransition } from '@affine/component';
import {
IconButton,
SafeArea,
startScopedViewTransition,
} from '@affine/component';
import { openSettingModalAtom } from '@affine/core/atoms';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
@@ -41,7 +45,7 @@ export const HomeHeader = () => {
return (
<div className={clsx(styles.root, { dense })}>
<div className={styles.float}>
<SafeArea top className={styles.float}>
<div className={styles.headerAndWsSelector}>
<div className={styles.wsSelectorWrapper}>
<WorkspaceSelector />
@@ -60,8 +64,10 @@ export const HomeHeader = () => {
<div className={styles.searchWrapper}>
<SearchInput placeholder={t['Quick search']()} onClick={navSearch} />
</div>
</div>
<div className={styles.space} />
</SafeArea>
<SafeArea top>
<div className={styles.space} />
</SafeArea>
</div>
);
};

View File

@@ -22,7 +22,6 @@ export const float = style({
top: 0,
width: '100%',
background: cssVarV2('layer/background/secondary'),
paddingTop: 12,
zIndex: 1,
});
export const space = style({