From 7a9b951e3e41f583865262ddd66a2fc38d542bc8 Mon Sep 17 00:00:00 2001 From: Zeke Zhang <958414905@qq.com> Date: Wed, 8 Jul 2026 16:33:18 +0800 Subject: [PATCH] fix(client-v2): improve mobile popup spacing (#10020) * fix(client-v2): improve mobile popup spacing * fix(client-v2): address mobile popup review --- .../models/blocks/form/QuickEditFormModel.tsx | 196 +++++++++-- .../QuickEditFormModel.quickEdit.test.ts | 321 +++++++++++++++++- .../mobile-components/MobileLazySelect.tsx | 30 +- .../fields/mobile-components/MobileSelect.tsx | 36 +- .../src/components/MobilePopup.style.ts | 15 +- .../src/components/MobilePopup.tsx | 40 ++- .../components/__tests__/MobilePopup.test.tsx | 109 ++++++ 7 files changed, 693 insertions(+), 54 deletions(-) create mode 100644 packages/core/flow-engine/src/components/__tests__/MobilePopup.test.tsx diff --git a/packages/core/client-v2/src/flow/models/blocks/form/QuickEditFormModel.tsx b/packages/core/client-v2/src/flow/models/blocks/form/QuickEditFormModel.tsx index a2ec7d3606a..3eb126d4d07 100644 --- a/packages/core/client-v2/src/flow/models/blocks/form/QuickEditFormModel.tsx +++ b/packages/core/client-v2/src/flow/models/blocks/form/QuickEditFormModel.tsx @@ -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; + +type QuickEditViewUpdateConfig = { + preventClose?: boolean; + [key: string]: unknown; +}; + +type QuickEditViewContainer = { + close: (result?: unknown, force?: boolean) => Promise | boolean | void; + update?: (newConfig: QuickEditViewUpdateConfig) => unknown; + beforeClose?: QuickEditViewBeforeCloseHandler; +}; + +type QuickEditViewContext = { + defineProperty?: (key: string, options: { value: unknown }) => void; +}; export function getQuickEditFieldProps(collectionField: CollectionField, fieldProps?: Record) { 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, +): 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 ( + } + 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 ( - } - model={model} - inputArgs={{ filterByTk, record, sourceFieldModelUid }} - /> - ); - }, + content, }); } @@ -168,13 +317,15 @@ export class QuickEditFormModel extends FlowModel { } render() { + const isMobileLayout = this.context.isMobileLayout; return (
{this.mapSubModels('fields', (field) => { @@ -191,7 +342,14 @@ export class QuickEditFormModel extends FlowModel { ); })}
- + +
+ +
)} diff --git a/packages/core/client-v2/src/flow/models/fields/mobile-components/MobileSelect.tsx b/packages/core/client-v2/src/flow/models/fields/mobile-components/MobileSelect.tsx index bcdb921740c..c1e286738ef 100644 --- a/packages/core/client-v2/src/flow/models/fields/mobile-components/MobileSelect.tsx +++ b/packages/core/client-v2/src/flow/models/fields/mobile-components/MobileSelect.tsx @@ -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) {
setSearchText(v)} showCancelButton />
-
+
{ - if (['multiple', 'tags'].includes(mode)) { + if (isMultiple) { setSelected(val); } else { setSelected(val[0]); @@ -91,10 +101,12 @@ export function MobileSelect(props) { ))}
- {['multiple', 'tags'].includes(mode) && ( - + {isMultiple && ( +
+ +
)} diff --git a/packages/core/flow-engine/src/components/MobilePopup.style.ts b/packages/core/flow-engine/src/components/MobilePopup.style.ts index e1921d96a85..e4853db10e9 100644 --- a/packages/core/flow-engine/src/components/MobilePopup.style.ts +++ b/packages/core/flow-engine/src/components/MobilePopup.style.ts @@ -124,7 +124,7 @@ const genStyleHook = ( // 等 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', }, diff --git a/packages/core/flow-engine/src/components/MobilePopup.tsx b/packages/core/flow-engine/src/components/MobilePopup.tsx index 45cc167c495..e71391fe7d9 100644 --- a/packages/core/flow-engine/src/components/MobilePopup.tsx +++ b/packages/core/flow-engine/src/components/MobilePopup.tsx @@ -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 = (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) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + event.preventDefault(); + closePopup(); + }, + [closePopup], + ); const theme = useMemo(() => { return { @@ -57,11 +79,8 @@ export const MobilePopup: FC = (props) => { onClose={closePopup} onMaskClick={closePopup} bodyClassName="nb-mobile-action-drawer-body" - bodyStyle={{ - padding: 0, - }} - maskStyle={style} - style={style} + bodyStyle={bodyStyle} + style={popupStyle} destroyOnClose >
@@ -69,13 +88,14 @@ export const MobilePopup: FC = (props) => { - {title} + {title} diff --git a/packages/core/flow-engine/src/components/__tests__/MobilePopup.test.tsx b/packages/core/flow-engine/src/components/__tests__/MobilePopup.test.tsx new file mode 100644 index 00000000000..538369f5cf7 --- /dev/null +++ b/packages/core/flow-engine/src/components/__tests__/MobilePopup.test.tsx @@ -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; + }) => ( +
+
+
+ {children} +
+
+ ); + + return { Popup }; + } + + return { + CloseOutline: () => , + }; + }, +})); + +describe('MobilePopup', () => { + const MobilePopupWithDrawerStyles = MobilePopup as React.ComponentType< + React.ComponentProps & { styles?: { body?: React.CSSProperties } } + >; + + it('applies drawer body styles as max bounds without forcing fixed half-window height', () => { + render( + + body + , + ); + + 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( + + body + , + ); + + 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( + + body + , + ); + + expect(screen.getByText(longTitle)).toHaveClass('nb-mobile-action-drawer-title'); + expect(screen.getByRole('button', { name: 'Close' })).toHaveClass('nb-mobile-action-drawer-close-icon'); + }); +});