Browse Source

fix: improve the scroll bar flashing when the modal box is opened (#4438)

pull/4322/merge
Vben 9 hours ago
committed by GitHub
parent
commit
161820dbc1
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 16
      packages/@core/base/shared/src/utils/dom.ts
  2. 8
      packages/@core/composables/src/use-scroll-lock.ts
  3. 114
      packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap
  4. 10
      packages/@core/preferences/__tests__/config.test.ts
  5. 6
      packages/@core/preferences/__tests__/preferences.test.ts
  6. 2
      packages/@core/preferences/tsconfig.json
  7. 146
      packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts
  8. 10
      packages/@core/ui-kit/form-ui/src/form-api.ts
  9. 2
      packages/@core/ui-kit/form-ui/tsconfig.json
  10. 3
      playground/src/locales/langs/en-US.json
  11. 3
      playground/src/locales/langs/zh-CN.json
  12. 8
      playground/src/router/routes/modules/examples.ts
  13. 208
      playground/src/views/examples/form/api.vue
  14. 3
      playground/src/views/examples/form/basic.vue
  15. 6
      playground/src/views/examples/form/custom.vue
  16. 17
      playground/src/views/examples/form/dynamic.vue
  17. 6
      playground/src/views/examples/form/query.vue
  18. 4
      playground/src/views/examples/form/rules.vue

16
packages/@core/base/shared/src/utils/dom.ts

@ -69,3 +69,19 @@ export function getScrollbarWidth() {
scrollDiv.remove();
return scrollbarWidth;
}
export function needsScrollbar() {
const doc = document.documentElement;
const body = document.body;
// 检查 body 的 overflow-y 样式
const overflowY = window.getComputedStyle(body).overflowY;
// 如果明确设置了需要滚动条的样式
if (overflowY === 'scroll' || overflowY === 'auto') {
return doc.scrollHeight > window.innerHeight;
}
// 在其他情况下,根据 scrollHeight 和 innerHeight 比较判断
return doc.scrollHeight > window.innerHeight;
}

8
packages/@core/composables/src/use-scroll-lock.ts

@ -1,4 +1,4 @@
import { getScrollbarWidth } from '@vben-core/shared/utils';
import { getScrollbarWidth, needsScrollbar } from '@vben-core/shared/utils';
import {
useScrollLock as _useScrollLock,
@ -13,6 +13,9 @@ export function useScrollLock() {
const scrollbarWidth = getScrollbarWidth();
tryOnBeforeMount(() => {
if (!needsScrollbar()) {
return;
}
document.body.style.paddingRight = `${scrollbarWidth}px`;
const layoutFixedNodes = document.querySelectorAll<HTMLElement>(
@ -30,6 +33,9 @@ export function useScrollLock() {
});
tryOnBeforeUnmount(() => {
if (!needsScrollbar()) {
return;
}
isLocked.value = false;
const layoutFixedNodes = document.querySelectorAll<HTMLElement>(
`.${SCROLL_FIXED_CLASS}`,

114
packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap

@ -0,0 +1,114 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`defaultPreferences immutability test > should not modify the config object 1`] = `
{
"app": {
"accessMode": "frontend",
"authPageLayout": "panel-right",
"checkUpdatesInterval": 1,
"colorGrayMode": false,
"colorWeakMode": false,
"compact": false,
"contentCompact": "wide",
"defaultAvatar": "https://unpkg.com/@vbenjs/static-source@0.1.6/source/avatar-v1.webp",
"dynamicTitle": true,
"enableCheckUpdates": true,
"enablePreferences": true,
"enableRefreshToken": false,
"isMobile": false,
"layout": "sidebar-nav",
"locale": "zh-CN",
"loginExpiredMode": "page",
"name": "Vben Admin",
"preferencesButtonPosition": "auto",
"watermark": false,
},
"breadcrumb": {
"enable": true,
"hideOnlyOne": false,
"showHome": false,
"showIcon": true,
"styleType": "normal",
},
"copyright": {
"companyName": "Vben",
"companySiteLink": "https://www.vben.pro",
"date": "2024",
"enable": true,
"icp": "",
"icpLink": "",
},
"footer": {
"enable": true,
"fixed": false,
},
"header": {
"enable": true,
"hidden": false,
"mode": "fixed",
},
"logo": {
"enable": true,
"source": "https://unpkg.com/@vbenjs/static-source@0.1.6/source/logo-v1.webp",
},
"navigation": {
"accordion": true,
"split": true,
"styleType": "rounded",
},
"shortcutKeys": {
"enable": true,
"globalLockScreen": true,
"globalLogout": true,
"globalPreferences": true,
"globalSearch": true,
},
"sidebar": {
"collapsed": false,
"collapsedShowTitle": false,
"enable": true,
"expandOnHover": true,
"extraCollapse": true,
"hidden": false,
"width": 224,
},
"tabbar": {
"dragable": true,
"enable": true,
"height": 38,
"keepAlive": true,
"persist": true,
"showIcon": true,
"showMaximize": true,
"showMore": true,
"showRefresh": true,
"styleType": "chrome",
},
"theme": {
"builtinType": "default",
"colorDestructive": "hsl(348 100% 61%)",
"colorPrimary": "hsl(212 100% 45%)",
"colorSuccess": "hsl(144 57% 58%)",
"colorWarning": "hsl(42 84% 61%)",
"mode": "dark",
"radius": "0.5",
"semiDarkHeader": false,
"semiDarkSidebar": true,
},
"transition": {
"enable": true,
"loading": true,
"name": "fade-slide",
"progress": true,
},
"widget": {
"fullscreen": true,
"globalSearch": true,
"languageToggle": true,
"lockScreen": true,
"notification": true,
"sidebarToggle": true,
"themeToggle": true,
},
}
`;

10
packages/@core/preferences/__tests__/config.test.ts

@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { defaultPreferences } from '../src/config';
describe('defaultPreferences immutability test', () => {
// 创建快照,确保默认配置对象不被修改
it('should not modify the config object', () => {
expect(defaultPreferences).toMatchSnapshot();
});
});

6
packages/@core/preferences/src/preferences.test.ts → packages/@core/preferences/__tests__/preferences.test.ts

@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { defaultPreferences } from './config';
import { PreferenceManager } from './preferences';
import { isDarkTheme } from './update-css-variables';
import { defaultPreferences } from '../src/config';
import { PreferenceManager } from '../src/preferences';
import { isDarkTheme } from '../src/update-css-variables';
describe('preferences', () => {
let preferenceManager: PreferenceManager;

2
packages/@core/preferences/tsconfig.json

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"include": ["src", "__tests__"],
"exclude": ["node_modules"]
}

146
packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts

@ -0,0 +1,146 @@
// 假设这个文件为 FormApi.ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FormApi } from '../src/form-api';
vi.mock('@vben-core/shared/utils', () => ({
bindMethods: vi.fn(),
createMerge: vi.fn((mergeFn) => {
return (stateOrFn, prev) => {
mergeFn(prev, 'key', stateOrFn);
return { ...prev, ...stateOrFn };
};
}),
isFunction: (fn: any) => typeof fn === 'function',
StateHandler: vi.fn().mockImplementation(() => ({
reset: vi.fn(),
setConditionTrue: vi.fn(),
waitForCondition: vi.fn().mockResolvedValue(true),
})),
}));
describe('formApi', () => {
let formApi: FormApi;
beforeEach(() => {
formApi = new FormApi();
});
it('should initialize with default state', () => {
expect(formApi.state).toEqual(
expect.objectContaining({
actionWrapperClass: '',
collapsed: false,
collapsedRows: 1,
commonConfig: {},
handleReset: undefined,
handleSubmit: undefined,
layout: 'horizontal',
resetButtonOptions: {},
schema: [],
showCollapseButton: false,
showDefaultActions: true,
submitButtonOptions: {},
wrapperClass: 'grid-cols-1',
}),
);
expect(formApi.isMounted).toBe(false);
});
it('should mount form actions', async () => {
const formActions: any = {
meta: {},
resetForm: vi.fn(),
setFieldValue: vi.fn(),
setValues: vi.fn(),
submitForm: vi.fn(),
validate: vi.fn(),
values: { name: 'test' },
};
await formApi.mount(formActions);
expect(formApi.isMounted).toBe(true);
expect(formApi.form).toEqual(formActions);
});
it('should get values from form', async () => {
const formActions: any = {
meta: {},
values: { name: 'test' },
};
await formApi.mount(formActions);
const values = await formApi.getValues();
expect(values).toEqual({ name: 'test' });
});
it('should set field value', async () => {
const setFieldValueMock = vi.fn();
const formActions: any = {
meta: {},
setFieldValue: setFieldValueMock,
values: { name: 'test' },
};
await formApi.mount(formActions);
await formApi.setFieldValue('name', 'new value');
expect(setFieldValueMock).toHaveBeenCalledWith(
'name',
'new value',
undefined,
);
});
it('should reset form', async () => {
const resetFormMock = vi.fn();
const formActions: any = {
meta: {},
resetForm: resetFormMock,
values: { name: 'test' },
};
await formApi.mount(formActions);
await formApi.resetForm();
expect(resetFormMock).toHaveBeenCalled();
});
it('should call handleSubmit on submit', async () => {
const handleSubmitMock = vi.fn();
const formActions: any = {
meta: {},
submitForm: vi.fn().mockResolvedValue(true),
values: { name: 'test' },
};
const state = {
handleSubmit: handleSubmitMock,
};
formApi.setState(state);
await formApi.mount(formActions);
const result = await formApi.submitForm();
expect(formActions.submitForm).toHaveBeenCalled();
expect(handleSubmitMock).toHaveBeenCalledWith({ name: 'test' });
expect(result).toEqual({ name: 'test' });
});
it('should unmount form and reset state', () => {
formApi.unmounted();
expect(formApi.isMounted).toBe(false);
expect(formApi.stateHandler.reset).toHaveBeenCalled();
});
it('should validate form', async () => {
const validateMock = vi.fn().mockResolvedValue(true);
const formActions: any = {
meta: {},
validate: validateMock,
};
await formApi.mount(formActions);
const isValid = await formApi.validate();
expect(validateMock).toHaveBeenCalled();
expect(isValid).toBe(true);
});
});

10
packages/@core/ui-kit/form-ui/src/form-api.ts

@ -43,13 +43,13 @@ function getDefaultState(): VbenFormProps {
}
export class FormApi {
// private prevState!: ModalState;
private state: null | VbenFormProps = null;
// private api: Pick<VbenFormProps, 'handleReset' | 'handleSubmit'>;
public form = {} as FormActions;
isMounted = false;
// private prevState!: ModalState;
public state: null | VbenFormProps = null;
stateHandler: StateHandler;
public store: Store<VbenFormProps>;
@ -92,6 +92,10 @@ export class FormApi {
this.store.batch(cb);
}
getState() {
return this.state;
}
async getValues() {
const form = await this.getForm();
return form.values;

2
packages/@core/ui-kit/form-ui/tsconfig.json

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"include": ["src", "__tests__"],
"exclude": ["node_modules"]
}

3
playground/src/locales/langs/en-US.json

@ -78,7 +78,8 @@
"query": "Query Form",
"rules": "Form Rules",
"dynamic": "Dynamic Form",
"custom": "Custom Component"
"custom": "Custom Component",
"api": "Api"
},
"captcha": {
"title": "Captcha",

3
playground/src/locales/langs/zh-CN.json

@ -78,7 +78,8 @@
"query": "查询表单",
"rules": "表单校验",
"dynamic": "动态表单",
"custom": "自定义组件"
"custom": "自定义组件",
"api": "Api"
},
"captcha": {
"title": "验证码",

8
playground/src/router/routes/modules/examples.ts

@ -99,6 +99,14 @@ const routes: RouteRecordRaw[] = [
title: $t('page.examples.form.custom'),
},
},
{
name: 'FormApiExample',
path: '/examples/form/api',
component: () => import('#/views/examples/form/api.vue'),
meta: {
title: $t('page.examples.form.api'),
},
},
],
},
],

208
playground/src/views/examples/form/api.vue

@ -0,0 +1,208 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button, Card, message, Space } from 'ant-design-vue';
import { useVbenForm } from '#/adapter';
const [BaseForm, formApi] = useVbenForm({
//
commonConfig: {
//
componentProps: {
class: 'w-full',
},
},
// 使 tailwindcss grid
//
handleSubmit: onSubmit,
// labelinputvertical
layout: 'horizontal',
// labelinput
schema: [
{
// #/adapter.ts
component: 'Input',
//
componentProps: {
placeholder: '请输入用户名',
},
//
fieldName: 'field1',
// label
label: 'field1',
},
{
component: 'Input',
componentProps: {
placeholder: '请输入',
},
fieldName: 'field2',
label: 'field2',
},
{
component: 'Input',
componentProps: {
placeholder: '请输入',
},
fieldName: 'field3',
label: 'field3',
},
],
// 321
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
});
function onSubmit(values: Record<string, any>) {
message.success({
content: `form values: ${JSON.stringify(values)}`,
});
}
function handleClick(
action:
| 'batchAddSchema'
| 'batchDeleteSchema'
| 'disabled'
| 'hiddenAction'
| 'hiddenResetButton'
| 'hiddenSubmitButton'
| 'labelWidth'
| 'resetDisabled'
| 'resetLabelWidth'
| 'showAction'
| 'showResetButton'
| 'showSubmitButton'
| 'updateActionAlign'
| 'updateResetButton'
| 'updateSubmitButton',
) {
switch (action) {
case 'labelWidth': {
formApi.setState({
commonConfig: {
labelWidth: 150,
},
});
break;
}
case 'resetLabelWidth': {
formApi.setState({
commonConfig: {
labelWidth: 100,
},
});
break;
}
case 'disabled': {
formApi.setState({ commonConfig: { disabled: true } });
break;
}
case 'resetDisabled': {
formApi.setState({ commonConfig: { disabled: false } });
break;
}
case 'hiddenAction': {
formApi.setState({ showDefaultActions: false });
break;
}
case 'showAction': {
formApi.setState({ showDefaultActions: true });
break;
}
case 'hiddenResetButton': {
formApi.setState({ resetButtonOptions: { show: false } });
break;
}
case 'showResetButton': {
formApi.setState({ resetButtonOptions: { show: true } });
break;
}
case 'hiddenSubmitButton': {
formApi.setState({ submitButtonOptions: { show: false } });
break;
}
case 'showSubmitButton': {
formApi.setState({ submitButtonOptions: { show: true } });
break;
}
case 'updateResetButton': {
formApi.setState({
resetButtonOptions: { disabled: true },
});
break;
}
case 'updateSubmitButton': {
formApi.setState({
submitButtonOptions: { loading: true },
});
break;
}
case 'updateActionAlign': {
formApi.setState({
// class
actionWrapperClass: 'text-center',
});
break;
}
case 'batchAddSchema': {
formApi.setState((prev) => {
const currentSchema = prev?.schema ?? [];
const newSchema = [];
for (let i = 0; i < 3; i++) {
newSchema.push({
component: 'Input',
componentProps: {
placeholder: '请输入',
},
fieldName: `field${i}${Date.now()}`,
label: `field+`,
});
}
return {
schema: [...currentSchema, ...newSchema],
};
});
break;
}
case 'batchDeleteSchema': {
formApi.setState((prev) => {
const currentSchema = prev?.schema ?? [];
return {
schema: currentSchema.slice(0, -3),
};
});
break;
}
}
}
</script>
<template>
<Page description="表单组件api操作示例。" title="表单组件">
<Space class="mb-5 flex-wrap">
<Button @click="handleClick('labelWidth')">更改labelWidth</Button>
<Button @click="handleClick('resetLabelWidth')">还原labelWidth</Button>
<Button @click="handleClick('disabled')">禁用表单</Button>
<Button @click="handleClick('resetDisabled')">解除禁用</Button>
<Button @click="handleClick('hiddenAction')">隐藏操作按钮</Button>
<Button @click="handleClick('showAction')">显示操作按钮</Button>
<Button @click="handleClick('hiddenResetButton')">隐藏重置按钮</Button>
<Button @click="handleClick('showResetButton')">显示重置按钮</Button>
<Button @click="handleClick('hiddenSubmitButton')">隐藏提交按钮</Button>
<Button @click="handleClick('showSubmitButton')">显示提交按钮</Button>
<Button @click="handleClick('updateResetButton')">修改重置按钮</Button>
<Button @click="handleClick('updateSubmitButton')">修改提交按钮</Button>
<Button @click="handleClick('updateActionAlign')">
调整操作按钮位置
</Button>
<Button @click="handleClick('batchAddSchema')"> 批量添加表单项 </Button>
<Button @click="handleClick('batchDeleteSchema')">
批量删除表单项
</Button>
</Space>
<Card title="操作示例">
<BaseForm />
</Card>
</Page>
</template>

3
playground/src/views/examples/form/basic.vue

@ -14,12 +14,11 @@ const [BaseForm, baseFormApi] = useVbenForm({
class: 'w-full',
},
},
// 使 tailwindcss grid
//
handleSubmit: onSubmit,
// labelinputvertical
layout: 'horizontal',
// labelinput
layout: 'horizontal',
schema: [
{
// #/adapter.ts

6
playground/src/views/examples/form/custom.vue

@ -16,12 +16,11 @@ const [BaseForm] = useVbenForm({
},
labelClass: 'w-2/6',
},
// 使 tailwindcss grid
//
handleSubmit: onSubmit,
// labelinputvertical
layout: 'horizontal',
// labelinput
layout: 'horizontal',
schema: [
{
// #/adapter.ts
@ -31,7 +30,6 @@ const [BaseForm] = useVbenForm({
suffix: () => h('span', { class: 'text-red-600' }, '元'),
},
{
// #/adapter.ts
component: 'Input',
fieldName: 'field1',
label: '自定义组件slot',
@ -41,14 +39,12 @@ const [BaseForm] = useVbenForm({
}),
},
{
// #/adapter.ts
component: h(Input, { placeholder: '请输入' }),
fieldName: 'field2',
label: '自定义组件',
rules: 'required',
},
{
// #/adapter.ts
component: 'Input',
fieldName: 'field3',
label: '自定义组件(slot)',

17
playground/src/views/examples/form/dynamic.vue

@ -6,10 +6,8 @@ import { Button, Card, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter';
const [Form, formApi] = useVbenForm({
// 使 tailwindcss grid
//
handleSubmit: onSubmit,
// labelinput
schema: [
{
component: 'Switch',
@ -55,12 +53,9 @@ const [Form, formApi] = useVbenForm({
show(values) {
return !!values.field2Switch;
},
//
triggerFields: ['field2Switch'],
},
//
fieldName: 'field2',
// label
label: '字段2',
},
{
@ -69,12 +64,9 @@ const [Form, formApi] = useVbenForm({
disabled(values) {
return !!values.field3Switch;
},
//
triggerFields: ['field3Switch'],
},
//
fieldName: 'field3',
// label
label: '字段3',
},
{
@ -83,12 +75,9 @@ const [Form, formApi] = useVbenForm({
required(values) {
return !!values.field4Switch;
},
//
triggerFields: ['field4Switch'],
},
//
fieldName: 'field4',
// label
label: '字段4',
},
{
@ -100,13 +89,10 @@ const [Form, formApi] = useVbenForm({
}
return null;
},
//
triggerFields: ['field1'],
},
//
fieldName: 'field5',
help: '当字段1的值为`123`时,必填',
// label
label: '动态rules',
},
{
@ -150,13 +136,10 @@ const [Form, formApi] = useVbenForm({
}
return {};
},
//
triggerFields: ['field2'],
},
//
fieldName: 'field6',
help: '当字段2的值为`123`时,更改下拉选项',
// label
label: '动态配置',
},
{

6
playground/src/views/examples/form/query.vue

@ -18,9 +18,8 @@ const [QueryForm] = useVbenForm({
//
handleSubmit: onSubmit,
// labelinputvertical
layout: 'horizontal',
// 使 tailwindcss grid
// labelinput
layout: 'horizontal',
schema: [
{
// #/adapter.ts
@ -101,9 +100,8 @@ const [QueryForm1] = useVbenForm({
//
handleSubmit: onSubmit,
// labelinputvertical
layout: 'horizontal',
// 使 tailwindcss grid
// labelinput
layout: 'horizontal',
schema: (() => {
const schema = [];
for (let index = 0; index < 14; index++) {

4
playground/src/views/examples/form/rules.vue

@ -13,12 +13,11 @@ const [Form, formApi] = useVbenForm({
class: 'w-full',
},
},
// 使 tailwindcss grid
//
handleSubmit: onSubmit,
// labelinputvertical
layout: 'horizontal',
// labelinput
layout: 'horizontal',
schema: [
{
// #/adapter.ts
@ -80,7 +79,6 @@ const [Form, formApi] = useVbenForm({
},
fieldName: 'number',
label: '数字',
// nullundefined
rules: 'required',
},
{

Loading…
Cancel
Save