fix(client-v2): improve mobile popup spacing (#10020)

* fix(client-v2): improve mobile popup spacing

* fix(client-v2): address mobile popup review
This commit is contained in:
Zeke Zhang
2026-07-08 16:33:18 +08:00
committed by GitHub
parent 099ec99fc3
commit 7a9b951e3e
7 changed files with 693 additions and 54 deletions
@@ -30,6 +30,37 @@ import { FormItemModel } from './FormItemModel';
export const QUICK_EDIT_POPOVER_MAX_HEIGHT = 'calc(100vh - 96px)';
export const QUICK_EDIT_FORM_MAX_HEIGHT = 'calc(100vh - 160px)';
export const QUICK_EDIT_MARKDOWN_HEIGHT = 'min(480px, calc(100vh - 320px))';
const QUICK_EDIT_MOBILE_DRAWER_HEIGHT = '50vh';
const QUICK_EDIT_MOBILE_FORM_MAX_HEIGHT = 'calc(50vh - var(--nb-mobile-page-header-height, 40px) - 132px)';
const QUICK_EDIT_MOBILE_CONTENT_PADDING = '8px var(--nb-mobile-page-tabs-content-padding, 12px) 0';
const QUICK_EDIT_MOBILE_ACTIONS_PADDING =
'8px var(--nb-mobile-page-tabs-content-padding, 12px) calc(80px + env(safe-area-inset-bottom, 0px))';
const QUICK_EDIT_MOBILE_MEDIA_QUERY = '(max-width: 768px)';
type QuickEditViewBeforeClosePayload = {
result?: unknown;
force?: boolean;
[key: string]: unknown;
};
type QuickEditViewBeforeCloseHandler = (
payload: QuickEditViewBeforeClosePayload,
) => Promise<boolean | void> | boolean | void;
type QuickEditViewUpdateConfig = {
preventClose?: boolean;
[key: string]: unknown;
};
type QuickEditViewContainer = {
close: (result?: unknown, force?: boolean) => Promise<boolean | void> | boolean | void;
update?: (newConfig: QuickEditViewUpdateConfig) => unknown;
beforeClose?: QuickEditViewBeforeCloseHandler;
};
type QuickEditViewContext = {
defineProperty?: (key: string, options: { value: unknown }) => void;
};
export function getQuickEditFieldProps(collectionField: CollectionField, fieldProps?: Record<string, any>) {
const nextProps = { ...collectionField.getComponentProps(), ...(fieldProps || {}) };
@@ -39,13 +70,92 @@ export function getQuickEditFieldProps(collectionField: CollectionField, fieldPr
return nextProps;
}
function getQuickEditMobileLayoutState(flowEngine: FlowEngine, sourceFieldModel?: FlowModel) {
const sourceMobileLayout = sourceFieldModel?.context?.isMobileLayout;
if (typeof sourceMobileLayout === 'boolean') {
return { isMobileLayout: sourceMobileLayout, inheritsMobileContext: sourceMobileLayout };
}
const engineMobileLayout = flowEngine.context?.isMobileLayout;
if (typeof engineMobileLayout === 'boolean') {
return { isMobileLayout: engineMobileLayout, inheritsMobileContext: engineMobileLayout };
}
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return { isMobileLayout: false, inheritsMobileContext: false };
}
return {
isMobileLayout: window.matchMedia(QUICK_EDIT_MOBILE_MEDIA_QUERY).matches,
inheritsMobileContext: false,
};
}
function getQuickEditTitle(
flowEngine: FlowEngine,
dataSourceKey: string,
collectionName: string,
fieldPath: string,
sourceFieldModel?: FlowModel,
fieldProps?: Record<string, unknown>,
): React.ReactNode {
const sourceColumnTitle = sourceFieldModel?.parent?.props?.title;
if (sourceColumnTitle) {
return sourceColumnTitle;
}
const fieldPropsTitle = fieldProps?.title;
if (fieldPropsTitle) {
return fieldPropsTitle as React.ReactNode;
}
const collectionField = flowEngine.context.dataSourceManager.getCollectionField(
`${dataSourceKey}.${collectionName}.${fieldPath}`,
) as CollectionField | undefined;
return collectionField?.title || fieldPath;
}
function createQuickEditViewContainer(view: QuickEditViewContainer): QuickEditViewContainer {
let preventClose = false;
let nextBeforeClose = view.beforeClose;
const beforeClose: QuickEditViewBeforeCloseHandler = async (payload) => {
if (preventClose && !payload?.force) {
return false;
}
const result = await nextBeforeClose?.(payload);
return result !== false;
};
view.beforeClose = beforeClose;
return {
...view,
close(result, force) {
return view.close(result, force);
},
update(newConfig) {
if (Object.prototype.hasOwnProperty.call(newConfig, 'preventClose')) {
preventClose = !!newConfig.preventClose;
}
return view.update?.(newConfig);
},
get beforeClose() {
return view.beforeClose;
},
set beforeClose(value) {
nextBeforeClose = value;
view.beforeClose = beforeClose;
},
};
}
export class QuickEditFormModel extends FlowModel {
fieldPath: string;
declare resource: SingleRecordResource;
declare collection: Collection;
viewContainer: any;
declare viewContainer: QuickEditViewContainer;
__onSubmitSuccess;
_fieldProps: any;
_onOk: any;
@@ -103,6 +213,59 @@ export class QuickEditFormModel extends FlowModel {
sourceFieldModel ? { delegate: sourceFieldModel.context } : undefined,
) as QuickEditFormModel;
const bodyStyles = {
maxHeight: QUICK_EDIT_POPOVER_MAX_HEIGHT,
overflowY: 'auto',
overscrollBehavior: 'contain',
};
const mobileBodyStyles = {
...bodyStyles,
maxHeight: QUICK_EDIT_MOBILE_DRAWER_HEIGHT,
};
const content = (view: QuickEditViewContainer, viewContext?: QuickEditViewContext) => {
if (mobileLayoutState.isMobileLayout) {
viewContext?.defineProperty?.('isMobileLayout', { value: true });
}
model.viewContainer = createQuickEditViewContainer(view);
model.__onSubmitSuccess = onSuccess;
model._fieldProps = fieldProps;
model._onOk = onOk;
return (
<FlowModelRenderer
fallback={<Skeleton.Input size="small" />}
model={model}
inputArgs={{ filterByTk, record, sourceFieldModelUid }}
/>
);
};
const mobileLayoutState = getQuickEditMobileLayoutState(flowEngine, sourceFieldModel);
if (mobileLayoutState.isMobileLayout) {
model.context.defineProperty('isMobileLayout', { value: true });
const viewer = sourceFieldModel?.context?.viewer || flowEngine.context.viewer;
const title = getQuickEditTitle(
flowEngine,
dataSourceKey,
collectionName,
fieldPath,
sourceFieldModel,
fieldProps,
);
await viewer.open({
type: 'drawer',
title,
closable: true,
placement: 'bottom',
inputArgs: {
isMobileLayout: true,
},
styles: {
body: mobileBodyStyles,
},
content,
});
return;
}
await flowEngine.context.viewer.open({
type: 'popover',
target,
@@ -110,24 +273,10 @@ export class QuickEditFormModel extends FlowModel {
styles: {
body: {
width: 420,
maxHeight: QUICK_EDIT_POPOVER_MAX_HEIGHT,
overflowY: 'auto',
overscrollBehavior: 'contain',
...bodyStyles,
},
},
content: (popover) => {
model.viewContainer = popover;
model.__onSubmitSuccess = onSuccess;
model._fieldProps = fieldProps;
model._onOk = onOk;
return (
<FlowModelRenderer
fallback={<Skeleton.Input size="small" />}
model={model}
inputArgs={{ filterByTk, record, sourceFieldModelUid }}
/>
);
},
content,
});
}
@@ -168,13 +317,15 @@ export class QuickEditFormModel extends FlowModel {
}
render() {
const isMobileLayout = this.context.isMobileLayout;
return (
<FormComponent model={this}>
<div
style={{
minHeight: 0,
overflowY: 'auto',
maxHeight: QUICK_EDIT_FORM_MAX_HEIGHT,
maxHeight: isMobileLayout ? QUICK_EDIT_MOBILE_FORM_MAX_HEIGHT : QUICK_EDIT_FORM_MAX_HEIGHT,
padding: isMobileLayout ? QUICK_EDIT_MOBILE_CONTENT_PADDING : undefined,
}}
>
{this.mapSubModels('fields', (field) => {
@@ -191,7 +342,14 @@ export class QuickEditFormModel extends FlowModel {
);
})}
</div>
<Space style={{ display: 'flex', justifyContent: 'flex-end', flexShrink: 0 }}>
<Space
style={{
display: 'flex',
justifyContent: 'flex-end',
flexShrink: 0,
padding: isMobileLayout ? QUICK_EDIT_MOBILE_ACTIONS_PADDING : undefined,
}}
>
<Button
onClick={() => {
this.viewContainer.close();
@@ -7,17 +7,54 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { FlowEngine, FlowModel, SingleRecordResource } from '@nocobase/flow-engine';
import { QuickEditFormModel } from '../QuickEditFormModel';
describe('QuickEditFormModel - quick edit save triggers API (regression)', () => {
let engine: FlowEngine;
const originalMatchMediaDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia');
beforeEach(() => {
engine = new FlowEngine();
});
afterEach(() => {
if (originalMatchMediaDescriptor) {
Object.defineProperty(window, 'matchMedia', originalMatchMediaDescriptor);
return;
}
delete (window as unknown as { matchMedia?: Window['matchMedia'] }).matchMedia;
});
const mockMatchMedia = (matches: boolean) => {
const matchMediaMock = vi.fn((query: string) => {
return {
matches,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
} as unknown as MediaQueryList;
});
Object.defineProperty(window, 'matchMedia', {
...(originalMatchMediaDescriptor || { configurable: true, writable: true }),
value: matchMediaMock,
});
};
const addUsersCollection = () => {
const ds = engine.context.dataSourceManager.getDataSource('main');
ds.addCollection({
name: 'users',
fields: [{ name: 'name', type: 'string', interface: 'input', uiSchema: { title: 'Name' } }],
});
};
it('uses source field context when opening quick edit', async () => {
engine.registerModels({ QuickEditFormModel });
engine.context.defineProperty('pageActive', { value: { value: false } });
@@ -47,6 +84,288 @@ describe('QuickEditFormModel - quick edit save triggers API (regression)', () =>
expect(quickEditModel?.context.pageActive.value).toBe(true);
});
it('opens quick edit in a mobile drawer when the source field layout is mobile', async () => {
engine.registerModels({ QuickEditFormModel });
addUsersCollection();
const engineOpen = vi.fn(async () => undefined);
const sourceOpen = vi.fn(async () => undefined);
engine.context.defineProperty('isMobileLayout', { value: false });
engine.context.defineProperty('viewer', { value: { open: engineOpen } });
const source = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'source-field' });
source.context.defineProperty('isMobileLayout', { value: true });
source.context.defineProperty('viewer', { value: { open: sourceOpen } });
await QuickEditFormModel.open({
flowEngine: engine,
target: document.createElement('div'),
dataSourceKey: 'main',
collectionName: 'users',
fieldPath: 'name',
record: {},
sourceFieldModelUid: source.uid,
});
expect(engineOpen).not.toHaveBeenCalled();
expect(sourceOpen).toHaveBeenCalledTimes(1);
expect(sourceOpen).toHaveBeenCalledWith(
expect.objectContaining({
type: 'drawer',
title: 'Name',
closable: true,
placement: 'bottom',
styles: {
body: expect.objectContaining({
maxHeight: '50vh',
}),
},
inputArgs: {
isMobileLayout: true,
},
}),
);
let quickEditModel: QuickEditFormModel | undefined;
engine.forEachModel((model) => {
if (model instanceof QuickEditFormModel) {
quickEditModel = model;
}
});
expect(quickEditModel?.context.isMobileLayout).toBe(true);
});
it('uses the source table column title for the mobile drawer header', async () => {
engine.registerModels({ QuickEditFormModel });
addUsersCollection();
const sourceOpen = vi.fn(async () => undefined);
engine.context.defineProperty('viewer', { value: { open: vi.fn(async () => undefined) } });
const column = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'table-column' });
column.setProps({ title: 'Custom marital status' });
const source = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'source-field', parentId: column.uid });
source.context.defineProperty('isMobileLayout', { value: true });
source.context.defineProperty('viewer', { value: { open: sourceOpen } });
await QuickEditFormModel.open({
flowEngine: engine,
target: document.createElement('div'),
dataSourceKey: 'main',
collectionName: 'users',
fieldPath: 'name',
record: {},
sourceFieldModelUid: source.uid,
});
expect(sourceOpen).toHaveBeenCalledWith(
expect.objectContaining({
type: 'drawer',
title: 'Custom marital status',
}),
);
});
it('uses the mobile drawer fallback on narrow viewports', async () => {
engine.registerModels({ QuickEditFormModel });
addUsersCollection();
mockMatchMedia(true);
const viewContext = {
defineProperty: vi.fn(),
};
const open = vi.fn(
async (config: { content: (view: { close: () => void }, context: typeof viewContext) => unknown }) => {
config.content({ close: vi.fn() }, viewContext);
},
);
engine.context.defineProperty('viewer', { value: { open } });
await QuickEditFormModel.open({
flowEngine: engine,
target: document.createElement('div'),
dataSourceKey: 'main',
collectionName: 'users',
fieldPath: 'name',
record: {},
});
expect(open).toHaveBeenCalledTimes(1);
expect(viewContext.defineProperty).toHaveBeenCalledWith('isMobileLayout', { value: true });
expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 768px)');
expect(open).toHaveBeenCalledWith(
expect.objectContaining({
type: 'drawer',
title: 'Name',
closable: true,
placement: 'bottom',
styles: {
body: expect.objectContaining({
maxHeight: '50vh',
}),
},
inputArgs: {
isMobileLayout: true,
},
}),
);
let quickEditModel: QuickEditFormModel | undefined;
engine.forEachModel((model) => {
if (model instanceof QuickEditFormModel) {
quickEditModel = model;
}
});
expect(quickEditModel?.context.isMobileLayout).toBe(true);
});
it('uses the engine mobile layout context without relying on matchMedia', async () => {
engine.registerModels({ QuickEditFormModel });
addUsersCollection();
mockMatchMedia(false);
const open = vi.fn(async () => undefined);
engine.context.defineProperty('isMobileLayout', { value: true });
engine.context.defineProperty('viewer', { value: { open } });
await QuickEditFormModel.open({
flowEngine: engine,
target: document.createElement('div'),
dataSourceKey: 'main',
collectionName: 'users',
fieldPath: 'name',
record: {},
});
expect(window.matchMedia).not.toHaveBeenCalled();
expect(open).toHaveBeenCalledTimes(1);
expect(open).toHaveBeenCalledWith(
expect.objectContaining({
type: 'drawer',
title: 'Name',
placement: 'bottom',
}),
);
});
it('keeps an explicit non-mobile layout context ahead of the narrow viewport fallback', async () => {
engine.registerModels({ QuickEditFormModel });
addUsersCollection();
mockMatchMedia(true);
const open = vi.fn(async () => undefined);
const target = document.createElement('div');
engine.context.defineProperty('isMobileLayout', { value: false });
engine.context.defineProperty('viewer', { value: { open } });
await QuickEditFormModel.open({
flowEngine: engine,
target,
dataSourceKey: 'main',
collectionName: 'users',
fieldPath: 'name',
record: {},
});
expect(window.matchMedia).not.toHaveBeenCalled();
expect(open).toHaveBeenCalledTimes(1);
expect(open).toHaveBeenCalledWith(
expect.objectContaining({
type: 'popover',
target,
placement: 'rightTop',
}),
);
});
it('keeps dynamic preventClose effective for mobile quick edit drawer containers', async () => {
engine.registerModels({ QuickEditFormModel });
addUsersCollection();
mockMatchMedia(true);
const originalBeforeClose = vi.fn(async () => true);
const drawerView = {
close: vi.fn(),
update: vi.fn(),
beforeClose: originalBeforeClose,
};
const open = vi.fn(async (config: { content: (view: typeof drawerView) => unknown }) => {
config.content(drawerView);
});
engine.context.defineProperty('viewer', { value: { open } });
await QuickEditFormModel.open({
flowEngine: engine,
target: document.createElement('div'),
dataSourceKey: 'main',
collectionName: 'users',
fieldPath: 'name',
record: {},
});
let quickEditModel: QuickEditFormModel | undefined;
engine.forEachModel((model) => {
if (model instanceof QuickEditFormModel) {
quickEditModel = model;
}
});
quickEditModel?.viewContainer.update?.({ preventClose: true });
expect(drawerView.update).toHaveBeenCalledWith({ preventClose: true });
await expect(drawerView.beforeClose?.({ force: false })).resolves.toBe(false);
expect(originalBeforeClose).not.toHaveBeenCalled();
quickEditModel?.viewContainer.update?.({ preventClose: false });
expect(drawerView.update).toHaveBeenCalledWith({ preventClose: false });
await expect(drawerView.beforeClose?.({ force: false })).resolves.toBe(true);
expect(originalBeforeClose).toHaveBeenCalledTimes(1);
});
it('keeps desktop quick edit in the existing right-top popover', async () => {
engine.registerModels({ QuickEditFormModel });
mockMatchMedia(false);
const open = vi.fn(async () => undefined);
const target = document.createElement('div');
engine.context.defineProperty('viewer', { value: { open } });
await QuickEditFormModel.open({
flowEngine: engine,
target,
dataSourceKey: 'main',
collectionName: 'users',
fieldPath: 'name',
record: {},
});
expect(open).toHaveBeenCalledTimes(1);
expect(open).toHaveBeenCalledWith(
expect.objectContaining({
type: 'popover',
target,
placement: 'rightTop',
styles: {
body: expect.objectContaining({
width: 420,
}),
},
}),
);
});
it('keeps mobile quick edit content naturally sized with half-window scroll bounds', () => {
engine.registerModels({ QuickEditFormModel });
const model = engine.createModel<QuickEditFormModel>({
use: QuickEditFormModel,
uid: 'quick-edit-style',
});
model.context.defineProperty('isMobileLayout', { value: true });
const result = QuickEditFormModel.prototype.render.call(model) as any;
const formBody = result.props.children[0];
const actions = result.props.children[1];
expect(formBody.props.style).toMatchObject({
maxHeight: 'calc(50vh - var(--nb-mobile-page-header-height, 40px) - 132px)',
overflowY: 'auto',
padding: '8px var(--nb-mobile-page-tabs-content-padding, 12px) 0',
});
expect(formBody.props.style.minHeight).toBe(0);
expect(actions.props.style).toMatchObject({
justifyContent: 'flex-end',
padding: '8px var(--nb-mobile-page-tabs-content-padding, 12px) calc(80px + env(safe-area-inset-bottom, 0px))',
});
});
it('calls update with filterByTk and merges primary key from ctx.collection/record', async () => {
// 1) 准备数据源与集合(含主键字段)
const dsm = engine.context.dataSourceManager;
@@ -23,6 +23,20 @@ import {
} from '../AssociationFieldModel/recordSelectShared';
import _ from 'lodash';
const mobileSelectSafeAreaPaddingBottom = 'calc(12px + env(safe-area-inset-bottom, 0px))';
const mobileSelectConfirmFooterStyle: CSSProperties = {
paddingBottom: mobileSelectSafeAreaPaddingBottom,
};
function getMobileSelectListStyle(hasConfirmFooter: boolean): CSSProperties {
return {
maxHeight: '60vh',
overflowY: 'auto',
paddingBottom: hasConfirmFooter ? undefined : mobileSelectSafeAreaPaddingBottom,
};
}
const labelClassName = css`
div {
white-space: nowrap !important;
@@ -280,13 +294,7 @@ export function MobileLazySelect(props: Readonly<LazySelectProps>) {
showCancelButton
/>
</div>
<div
style={{
maxHeight: '60vh',
overflowY: 'auto',
}}
onScroll={handleScroll}
>
<div style={getMobileSelectListStyle(isMultiple)} onScroll={handleScroll}>
<CheckList multiple={isMultiple} value={selectedValueIds} onChange={handleListChange}>
{realOptions.map((item) => {
const optionValue = item?.[valueKey];
@@ -311,9 +319,11 @@ export function MobileLazySelect(props: Readonly<LazySelectProps>) {
)}
</div>
{isMultiple && (
<Button block color="primary" onClick={handleConfirm} style={{ marginTop: '16px' }}>
{t('Confirm')}
</Button>
<div style={mobileSelectConfirmFooterStyle}>
<Button block color="primary" onClick={handleConfirm} style={{ marginTop: '16px' }}>
{t('Confirm')}
</Button>
</div>
)}
</Popup>
</>
@@ -12,8 +12,23 @@ import { Select } from 'antd';
import { Button, CheckList, Popup, SearchBar } from 'antd-mobile';
import React, { useEffect, useMemo, useState } from 'react';
const mobileSelectSafeAreaPaddingBottom = 'calc(12px + env(safe-area-inset-bottom, 0px))';
const mobileSelectConfirmFooterStyle: React.CSSProperties = {
paddingBottom: mobileSelectSafeAreaPaddingBottom,
};
function getMobileSelectListStyle(hasConfirmFooter: boolean): React.CSSProperties {
return {
maxHeight: '60vh',
overflowY: 'auto',
paddingBottom: hasConfirmFooter ? undefined : mobileSelectSafeAreaPaddingBottom,
};
}
export function MobileSelect(props) {
const { value, displayValue, onChange, onChangeComplete, disabled, options = [], mode } = props;
const isMultiple = ['multiple', 'tags'].includes(mode);
const ctx = useFlowModelContext();
const t = ctx.t;
const [visible, setVisible] = useState(false);
@@ -64,17 +79,12 @@ export function MobileSelect(props) {
<div style={{ margin: '10px' }}>
<SearchBar placeholder={t('search')} value={searchText} onChange={(v) => setSearchText(v)} showCancelButton />
</div>
<div
style={{
maxHeight: '60vh',
overflowY: 'auto',
}}
>
<div style={getMobileSelectListStyle(isMultiple)}>
<CheckList
multiple={['multiple', 'tags'].includes(mode)}
multiple={isMultiple}
value={Array.isArray(selected) ? selected : [selected]}
onChange={(val) => {
if (['multiple', 'tags'].includes(mode)) {
if (isMultiple) {
setSelected(val);
} else {
setSelected(val[0]);
@@ -91,10 +101,12 @@ export function MobileSelect(props) {
))}
</CheckList>
</div>
{['multiple', 'tags'].includes(mode) && (
<Button block color="primary" onClick={handleConfirm} style={{ marginTop: '16px' }}>
{t('Confirm')}
</Button>
{isMultiple && (
<div style={mobileSelectConfirmFooterStyle}>
<Button block color="primary" onClick={handleConfirm} style={{ marginTop: '16px' }}>
{t('Confirm')}
</Button>
</div>
)}
</Popup>
</>
@@ -124,7 +124,7 @@ const genStyleHook = <ComponentName extends OverrideComponent>(
// 等 https://github.com/ant-design/cssinjs/pull/176 合并后,可以去掉这层缓存
const memoizedWrapSSR = useMemo(() => {
return wrapSSR;
}, [theme, token, hashId, prefixCls, iconPrefixCls, rootPrefixCls, props]);
}, [wrapSSR]);
return {
wrapSSR: memoizedWrapSSR,
@@ -147,7 +147,7 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
borderBottom: `1px solid ${token.colorSplit}`,
position: 'sticky',
top: 0,
backgroundColor: 'white',
backgroundColor: token.colorBgContainer,
zIndex: 1000,
// to match the button named 'Add block'
@@ -159,12 +159,23 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
'.nb-mobile-action-drawer-placeholder': {
display: 'inline-block',
padding: 12,
flex: '0 0 auto',
visibility: 'hidden',
},
'.nb-mobile-action-drawer-title': {
flex: '1 1 auto',
minWidth: 0,
overflow: 'hidden',
textAlign: 'center',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
'.nb-mobile-action-drawer-close-icon': {
display: 'inline-block',
padding: 12,
flex: '0 0 auto',
cursor: 'pointer',
},
@@ -8,7 +8,7 @@
*/
import { ConfigProvider } from 'antd';
import React, { FC, ReactNode, useMemo } from 'react';
import React, { FC, ReactNode, useCallback, useMemo } from 'react';
import { useMobileActionDrawerStyle } from './MobilePopup.style';
import { useTranslation } from 'react-i18next';
import { lazy } from '../lazy-helper';
@@ -31,11 +31,33 @@ export const MobilePopup: FC<MobilePopupProps> = (props) => {
const { t } = useTranslation();
const { componentCls, hashId } = useMobileActionDrawerStyle();
const style = useMemo(() => {
const bodyStyles = (props as MobilePopupProps & { styles?: { body?: React.CSSProperties } }).styles?.body;
const popupStyle = useMemo(() => {
return {
minHeight,
minHeight: bodyStyles?.minHeight ?? minHeight,
height: bodyStyles?.height,
maxHeight: bodyStyles?.maxHeight,
};
}, [minHeight]);
}, [bodyStyles?.height, bodyStyles?.maxHeight, bodyStyles?.minHeight, minHeight]);
const bodyStyle = useMemo(() => {
return {
padding: 0,
...bodyStyles,
};
}, [bodyStyles]);
const handleCloseKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLSpanElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
closePopup();
},
[closePopup],
);
const theme = useMemo(() => {
return {
@@ -57,11 +79,8 @@ export const MobilePopup: FC<MobilePopupProps> = (props) => {
onClose={closePopup}
onMaskClick={closePopup}
bodyClassName="nb-mobile-action-drawer-body"
bodyStyle={{
padding: 0,
}}
maskStyle={style}
style={style}
bodyStyle={bodyStyle}
style={popupStyle}
destroyOnClose
>
<div className="nb-mobile-action-drawer-header">
@@ -69,13 +88,14 @@ export const MobilePopup: FC<MobilePopupProps> = (props) => {
<span className="nb-mobile-action-drawer-placeholder">
<CloseOutline />
</span>
<span>{title}</span>
<span className="nb-mobile-action-drawer-title">{title}</span>
<span
className="nb-mobile-action-drawer-close-icon"
onClick={closePopup}
role="button"
tabIndex={0}
aria-label={t('Close')}
onKeyDown={handleCloseKeyDown}
>
<CloseOutline />
</span>
@@ -0,0 +1,109 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { MobilePopup } from '../MobilePopup';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('../MobilePopup.style', () => ({
useMobileActionDrawerStyle: () => ({
componentCls: 'nb-mobile-action-drawer',
hashId: 'hash',
}),
}));
vi.mock('../../lazy-helper', () => ({
lazy: (_loader: unknown, name: string) => {
if (name === 'Popup') {
const Popup = ({
children,
bodyStyle,
maskStyle,
style,
}: {
children: React.ReactNode;
bodyStyle?: React.CSSProperties;
maskStyle?: React.CSSProperties;
style?: React.CSSProperties;
}) => (
<div data-testid="mobile-popup" style={style}>
<div data-testid="mobile-popup-mask" style={maskStyle} />
<div data-testid="mobile-popup-body" style={bodyStyle}>
{children}
</div>
</div>
);
return { Popup };
}
return {
CloseOutline: () => <span data-testid="close-outline" />,
};
},
}));
describe('MobilePopup', () => {
const MobilePopupWithDrawerStyles = MobilePopup as React.ComponentType<
React.ComponentProps<typeof MobilePopup> & { styles?: { body?: React.CSSProperties } }
>;
it('applies drawer body styles as max bounds without forcing fixed half-window height', () => {
render(
<MobilePopupWithDrawerStyles visible title="Title" styles={{ body: { maxHeight: '50vh' } }} onClose={vi.fn()}>
body
</MobilePopupWithDrawerStyles>,
);
expect(screen.getByTestId('mobile-popup')).toHaveStyle({
maxHeight: '50vh',
});
expect(screen.getByTestId('mobile-popup-body')).toHaveStyle({
maxHeight: '50vh',
});
expect(screen.getByTestId('mobile-popup-mask')).not.toHaveStyle({
maxHeight: '50vh',
});
});
it('closes from the header close icon with Enter or Space', () => {
const onClose = vi.fn();
render(
<MobilePopup visible title="Title" onClose={onClose}>
body
</MobilePopup>,
);
const closeButton = screen.getByRole('button', { name: 'Close' });
fireEvent.keyDown(closeButton, { key: 'Enter' });
fireEvent.keyDown(closeButton, { key: ' ' });
expect(onClose).toHaveBeenCalledTimes(2);
});
it('keeps long header titles in a constrained title element separate from the close button', () => {
const longTitle = 'A very long table column title that should not push the close button outside the drawer';
render(
<MobilePopup visible title={longTitle} onClose={vi.fn()}>
body
</MobilePopup>,
);
expect(screen.getByText(longTitle)).toHaveClass('nb-mobile-action-drawer-title');
expect(screen.getByRole('button', { name: 'Close' })).toHaveClass('nb-mobile-action-drawer-close-icon');
});
});