@veltra/desktop 1.7.9 → 1.7.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/components/batch-edit/batch-edit-form.js +7 -3
  2. package/dist/components/batch-edit/batch-edit-form.js.map +1 -1
  3. package/dist/components/batch-edit/batch-edit.js +1 -0
  4. package/dist/components/batch-edit/batch-edit.js.map +1 -1
  5. package/dist/components/batch-edit/use-edit-state.js.map +1 -1
  6. package/dist/components/button/button.js +0 -1
  7. package/dist/components/button/button.js.map +1 -1
  8. package/dist/components/drawer/drawer.js +18 -10
  9. package/dist/components/drawer/drawer.js.map +1 -1
  10. package/dist/components/drawer/style2.css +15 -0
  11. package/dist/components/form/form.js.map +1 -1
  12. package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js +31 -6
  13. package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js.map +1 -1
  14. package/dist/components/form/is-field-modified.js +12 -0
  15. package/dist/components/form/is-field-modified.js.map +1 -0
  16. package/dist/components/form/style2.css +37 -1
  17. package/dist/components/form/use-form-fields.js +11 -6
  18. package/dist/components/form/use-form-fields.js.map +1 -1
  19. package/dist/components/form-item/form-item.js.map +1 -1
  20. package/dist/components/form-item/form-item.vue_vue_type_script_setup_true_lang.js +26 -6
  21. package/dist/components/form-item/form-item.vue_vue_type_script_setup_true_lang.js.map +1 -1
  22. package/dist/types/action.d.ts +6 -1
  23. package/dist/types/batch-edit.d.ts +2 -0
  24. package/dist/types/button.d.ts +5 -1
  25. package/dist/types/drawer.d.ts +6 -3
  26. package/dist/types/form-item.d.ts +4 -1
  27. package/dist/types/form.d.ts +10 -1
  28. package/dist/types/layout.d.ts +2 -0
  29. package/package.json +6 -6
  30. package/src/components/batch-edit/__test__/batch-edit.test.ts +138 -0
  31. package/src/components/batch-edit/batch-edit-form.vue +18 -4
  32. package/src/components/batch-edit/use-edit-state.ts +1 -1
  33. package/src/components/button/__test__/button.test.ts +48 -0
  34. package/src/components/button/button.vue +1 -8
  35. package/src/components/drawer/__test__/drawer.test.ts +118 -0
  36. package/src/components/drawer/drawer.vue +5 -2
  37. package/src/components/drawer/style.scss +15 -0
  38. package/src/components/form/__test__/form.test.ts +124 -0
  39. package/src/components/form/__test__/is-field-modified.test.ts +37 -0
  40. package/src/components/form/form.vue +28 -4
  41. package/src/components/form/is-field-modified.ts +16 -0
  42. package/src/components/form/style.scss +53 -2
  43. package/src/components/form/use-form-fields.ts +20 -6
  44. package/src/components/form-item/__test__/form-item.test.ts +129 -0
  45. package/src/components/form-item/form-item.vue +47 -8
  46. package/src/types/action.ts +6 -1
  47. package/src/types/batch-edit.ts +2 -0
  48. package/src/types/button.ts +5 -1
  49. package/src/types/drawer.ts +6 -3
  50. package/src/types/form-item.ts +4 -1
  51. package/src/types/form.ts +10 -2
  52. package/src/types/layout.ts +2 -0
@@ -2,9 +2,9 @@ import _sfc_main from "../tip/tip.js";
2
2
  import _sfc_main$1 from "../grid/grid-item.js";
3
3
  import { defineField, formItemCls } from "./helper.js";
4
4
  import { validateField } from "./validate.js";
5
- import { Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, normalizeClass, normalizeStyle, onBeforeUnmount, openBlock, renderSlot, shallowRef, toDisplayString, unref, watch, withCtx } from "vue";
5
+ import { Transition, cloneVNode, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, normalizeClass, normalizeStyle, onBeforeUnmount, openBlock, renderSlot, shallowRef, toDisplayString, unref, watch, withCtx } from "vue";
6
6
  import { useConfig, useFallbackProps } from "@veltra/compositions";
7
- import { bem, injectFormContext, withUnit } from "@veltra/utils";
7
+ import { bem, extractNormalVNodes, injectFormContext, withUnit } from "@veltra/utils";
8
8
  import { o } from "@cat-kit/core";
9
9
  //#region src/components/form-item/form-item.vue?vue&type=script&setup=true&lang.tsx
10
10
  var form_item_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComponent({
@@ -25,10 +25,27 @@ var form_item_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ define
25
25
  rules: {},
26
26
  size: {}
27
27
  },
28
- setup(__props) {
28
+ emits: ["change"],
29
+ setup(__props, { emit: __emit }) {
29
30
  const props = __props;
31
+ const emit = __emit;
30
32
  /** 表单组件上下文 */
31
- const { formProps, registerField, unregisterField, shouldValidate, handleFieldChange } = injectFormContext();
33
+ const { formProps, registerField, unregisterField, shouldValidate, handleFieldUpdate, handleFieldChange } = injectFormContext();
34
+ function wrapControlChange(node) {
35
+ if (!node || typeof node.type === "symbol") return node;
36
+ return cloneVNode(node, { onChange: (...args) => {
37
+ emit("change", ...args);
38
+ if (props.field) handleFieldChange?.(props.field, ...args);
39
+ } });
40
+ }
41
+ /**
42
+ * 承载默认插槽并拦截控件 change。
43
+ * 必须经填充插槽接收内容(而非直接读 UFormItem 的 $slots):
44
+ * 读父级 $slots 时组件自身没有更新路径,父级重渲染后动态 props(disabled、data 等)会冻结在首帧。
45
+ */
46
+ function ContentSlot(_, { slots }) {
47
+ return extractNormalVNodes(slots.default?.() ?? []).map(wrapControlChange);
48
+ }
32
49
  const { config } = useConfig();
33
50
  const { size, readonly } = useFallbackProps([formProps ?? {}, props], {
34
51
  size: "default",
@@ -77,7 +94,7 @@ var form_item_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ define
77
94
  stopWatchFieldValue?.();
78
95
  if (!field || !model) return;
79
96
  stopWatchFieldValue = watch(() => o(model).get(field), (value) => {
80
- handleFieldChange?.(field, value);
97
+ handleFieldUpdate?.(field, value);
81
98
  fieldItem.validate();
82
99
  });
83
100
  }, { immediate: true });
@@ -102,7 +119,10 @@ var form_item_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ define
102
119
  default: withCtx(() => [createElementVNode("span", { class: normalizeClass(unref(formItemCls).e("tip-label")) }, [renderSlot(_ctx.$slots, "label", {}, () => [createTextVNode(toDisplayString(__props.label) + toDisplayString(labelPosition.value === "top" ? "" : ":"), 1)])], 2)]),
103
120
  _: 3
104
121
  }, 8, ["content", "class"])) : renderSlot(_ctx.$slots, "label", {}, () => [createTextVNode(toDisplayString(__props.label) + toDisplayString(labelPosition.value === "top" ? "" : ":"), 1)], void 0, 1)], 6)) : createCommentVNode("v-if", true), createElementVNode("section", { class: normalizeClass(unref(formItemCls).e("wrapper")) }, [
105
- createElementVNode("div", { class: normalizeClass(unref(formItemCls).e("content")) }, [renderSlot(_ctx.$slots, "default")], 2),
122
+ createElementVNode("div", { class: normalizeClass(unref(formItemCls).e("content")) }, [createVNode(ContentSlot, null, {
123
+ default: withCtx(() => [renderSlot(_ctx.$slots, "default")]),
124
+ _: 3
125
+ })], 2),
106
126
  createCommentVNode(" 只有表单控件处于非只读状态时,才显示错误提示 "),
107
127
  !unref(readonly) && !unref(formProps)?.noTips ? (openBlock(), createElementBlock("section", {
108
128
  key: 0,
@@ -1 +1 @@
1
- {"version":3,"file":"form-item.vue_vue_type_script_setup_true_lang.js","names":["$slots"],"sources":["../../../src/components/form-item/form-item.vue"],"sourcesContent":["<template>\n <u-grid-item :span=\"span\" :class=\"className\">\n <label\n v-if=\"props.label || $slots.label\"\n :class=\"[cls.e('label'), bem.is('required', !!rules?.required)]\"\n :style=\"labelStyles\"\n >\n <u-tip v-if=\"tips\" :content=\"tips\" :class=\"cls.e('tips')\" :show-delay=\"500\">\n <span :class=\"cls.e('tip-label')\">\n <slot name=\"label\">{{ label }}{{ labelPosition === 'top' ? '' : ':' }}</slot>\n </span>\n </u-tip>\n <template v-else>\n <slot name=\"label\">{{ label }}{{ labelPosition === 'top' ? '' : ':' }}</slot>\n </template>\n </label>\n\n <section :class=\"cls.e('wrapper')\">\n <div :class=\"cls.e('content')\">\n <slot></slot>\n </div>\n\n <!-- 只有表单控件处于非只读状态时,才显示错误提示 -->\n <section :class=\"cls.e('error')\" v-if=\"!readonly && !formProps?.noTips\">\n <transition name=\"form-item-tips\" mode=\"out-in\">\n <span :class=\"cls.e('error-text')\" v-if=\"!!errorTip\">\n {{ errorTip }}\n </span>\n </transition>\n </section>\n </section>\n </u-grid-item>\n</template>\n\n<script lang=\"tsx\" setup>\nimport { o } from '@cat-kit/core'\nimport { useConfig, useFallbackProps } from '@veltra/compositions'\nimport { bem, withUnit } from '@veltra/utils'\nimport { injectFormContext } from '@veltra/utils'\nimport { type CSSProperties, computed, onBeforeUnmount, shallowRef, watch } from 'vue'\n\nimport type { FormItemProps, ComponentSize } from '../../types'\nimport { UGridItem } from '../grid'\nimport { UTip } from '../tip'\nimport { formItemCls as cls, defineField } from './helper'\nimport { validateField } from './validate'\n\ndefineOptions({ name: 'UFormItem' })\n\nconst props = withDefaults(defineProps<FormItemProps>(), { readonly: undefined })\n\ndefineSlots<{\n /** 标签插槽 */\n label?: () => any\n default?: () => any\n}>()\n\n/** 表单组件上下文 */\nconst { formProps, registerField, unregisterField, shouldValidate, handleFieldChange } =\n injectFormContext()\n\nconst { config } = useConfig()\n\nconst { size, readonly } = useFallbackProps([formProps ?? {}, props], {\n size: 'default' as ComponentSize,\n readonly: false\n})\n\nconst errorTip = shallowRef<string>()\n/** 递增序号,丢弃过期的异步校验结果 */\nlet validateSeq = 0\n/** 最近一次 validate 的 Promise,供过期调用等待最新结果 */\nlet latestValidatePromise: Promise<boolean> = Promise.resolve(true)\n\nconst labelPosition = computed(() => props.labelPosition ?? formProps?.labelPosition ?? 'left')\n\nconst className = computed(() => {\n return [\n cls.b,\n cls.m(size.value),\n bem.is('error', !!errorTip.value),\n bem.is('label-top', labelPosition.value === 'top')\n ].join(' ')\n})\n\n/** label样式 */\nconst labelStyles = computed<CSSProperties>(() => {\n if (labelPosition.value === 'top') return {}\n return {\n width: withUnit(props.labelWidth ?? formProps?.labelWidth ?? config.form.labelWidth, 'px')\n }\n})\n\nconst fieldItem = defineField({\n clearValidate() {\n errorTip.value = ''\n },\n async validate() {\n if (!props.field || !formProps?.model || !props.rules || !shouldValidate?.()) return true\n\n const seq = ++validateSeq\n\n latestValidatePromise = validateField(formProps.model!, props.field!, props.rules!).then(\n (tip) => {\n if (seq !== validateSeq) return latestValidatePromise\n\n errorTip.value = tip\n return !errorTip.value\n }\n )\n return latestValidatePromise\n }\n})\n\nwatch(\n () => props.field,\n (field, oldField) => {\n if (oldField) {\n unregisterField?.(oldField)\n }\n if (field) {\n registerField?.(field, fieldItem)\n }\n },\n { immediate: true }\n)\n\nlet stopWatchFieldValue: (() => void) | undefined\n\nwatch(\n [() => formProps?.model, () => props.field],\n ([model, field]) => {\n stopWatchFieldValue?.()\n\n if (!field || !model) return\n\n stopWatchFieldValue = watch(\n () => o(model).get(field),\n (value) => {\n handleFieldChange?.(field, value)\n fieldItem.validate()\n }\n )\n },\n { immediate: true }\n)\n\nonBeforeUnmount(() => {\n props.field && unregisterField?.(props.field)\n})\n</script>\n"],"mappings":"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"}
1
+ {"version":3,"file":"form-item.vue_vue_type_script_setup_true_lang.js","names":["$slots"],"sources":["../../../src/components/form-item/form-item.vue"],"sourcesContent":["<template>\n <u-grid-item :span=\"span\" :class=\"className\">\n <label\n v-if=\"props.label || $slots.label\"\n :class=\"[cls.e('label'), bem.is('required', !!rules?.required)]\"\n :style=\"labelStyles\"\n >\n <u-tip v-if=\"tips\" :content=\"tips\" :class=\"cls.e('tips')\" :show-delay=\"500\">\n <span :class=\"cls.e('tip-label')\">\n <slot name=\"label\">{{ label }}{{ labelPosition === 'top' ? '' : ':' }}</slot>\n </span>\n </u-tip>\n <template v-else>\n <slot name=\"label\">{{ label }}{{ labelPosition === 'top' ? '' : ':' }}</slot>\n </template>\n </label>\n\n <section :class=\"cls.e('wrapper')\">\n <div :class=\"cls.e('content')\">\n <ContentSlot>\n <slot></slot>\n </ContentSlot>\n </div>\n\n <!-- 只有表单控件处于非只读状态时,才显示错误提示 -->\n <section :class=\"cls.e('error')\" v-if=\"!readonly && !formProps?.noTips\">\n <transition name=\"form-item-tips\" mode=\"out-in\">\n <span :class=\"cls.e('error-text')\" v-if=\"!!errorTip\">\n {{ errorTip }}\n </span>\n </transition>\n </section>\n </section>\n </u-grid-item>\n</template>\n\n<script lang=\"tsx\" setup>\nimport { o } from '@cat-kit/core'\nimport { useConfig, useFallbackProps } from '@veltra/compositions'\nimport { bem, extractNormalVNodes, withUnit } from '@veltra/utils'\nimport { injectFormContext } from '@veltra/utils'\nimport {\n type CSSProperties,\n type SetupContext,\n type VNode,\n cloneVNode,\n computed,\n onBeforeUnmount,\n shallowRef,\n watch\n} from 'vue'\n\nimport type { FormItemEmits, FormItemProps, ComponentSize } from '../../types'\nimport { UGridItem } from '../grid'\nimport { UTip } from '../tip'\nimport { formItemCls as cls, defineField } from './helper'\nimport { validateField } from './validate'\n\ndefineOptions({ name: 'UFormItem' })\n\nconst props = withDefaults(defineProps<FormItemProps>(), { readonly: undefined })\n\nconst emit = defineEmits<FormItemEmits>()\n\ndefineSlots<{\n /** 标签插槽 */\n label?: () => any\n default?: () => any\n}>()\n\n/** 表单组件上下文 */\nconst {\n formProps,\n registerField,\n unregisterField,\n shouldValidate,\n handleFieldUpdate,\n handleFieldChange\n} = injectFormContext()\n\nfunction wrapControlChange(node: VNode) {\n if (!node || typeof node.type === 'symbol') return node\n\n return cloneVNode(node, {\n onChange: (...args: any[]) => {\n emit('change', ...args)\n if (props.field) handleFieldChange?.(props.field, ...args)\n }\n })\n}\n\n/**\n * 承载默认插槽并拦截控件 change。\n * 必须经填充插槽接收内容(而非直接读 UFormItem 的 $slots):\n * 读父级 $slots 时组件自身没有更新路径,父级重渲染后动态 props(disabled、data 等)会冻结在首帧。\n */\nfunction ContentSlot(_: unknown, { slots }: SetupContext) {\n return extractNormalVNodes(slots.default?.() ?? []).map(wrapControlChange)\n}\n\nconst { config } = useConfig()\n\nconst { size, readonly } = useFallbackProps([formProps ?? {}, props], {\n size: 'default' as ComponentSize,\n readonly: false\n})\n\nconst errorTip = shallowRef<string>()\n/** 递增序号,丢弃过期的异步校验结果 */\nlet validateSeq = 0\n/** 最近一次 validate 的 Promise,供过期调用等待最新结果 */\nlet latestValidatePromise: Promise<boolean> = Promise.resolve(true)\n\nconst labelPosition = computed(() => props.labelPosition ?? formProps?.labelPosition ?? 'left')\n\nconst className = computed(() => {\n return [\n cls.b,\n cls.m(size.value),\n bem.is('error', !!errorTip.value),\n bem.is('label-top', labelPosition.value === 'top')\n ].join(' ')\n})\n\n/** label样式 */\nconst labelStyles = computed<CSSProperties>(() => {\n if (labelPosition.value === 'top') return {}\n return {\n width: withUnit(props.labelWidth ?? formProps?.labelWidth ?? config.form.labelWidth, 'px')\n }\n})\n\nconst fieldItem = defineField({\n clearValidate() {\n errorTip.value = ''\n },\n async validate() {\n if (!props.field || !formProps?.model || !props.rules || !shouldValidate?.()) return true\n\n const seq = ++validateSeq\n\n latestValidatePromise = validateField(formProps.model!, props.field!, props.rules!).then(\n (tip) => {\n if (seq !== validateSeq) return latestValidatePromise\n\n errorTip.value = tip\n return !errorTip.value\n }\n )\n return latestValidatePromise\n }\n})\n\nwatch(\n () => props.field,\n (field, oldField) => {\n if (oldField) {\n unregisterField?.(oldField)\n }\n if (field) {\n registerField?.(field, fieldItem)\n }\n },\n { immediate: true }\n)\n\nlet stopWatchFieldValue: (() => void) | undefined\n\nwatch(\n [() => formProps?.model, () => props.field],\n ([model, field]) => {\n stopWatchFieldValue?.()\n\n if (!field || !model) return\n\n stopWatchFieldValue = watch(\n () => o(model).get(field),\n (value) => {\n handleFieldUpdate?.(field, value)\n fieldItem.validate()\n }\n )\n },\n { immediate: true }\n)\n\nonBeforeUnmount(() => {\n props.field && unregisterField?.(props.field)\n})\n</script>\n"],"mappings":"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"}
@@ -3,7 +3,12 @@ import { ColorType, DeconstructValue } from "@veltra/utils";
3
3
  //#region src/types/action.d.ts
4
4
  /** 操作组件属性 */
5
5
  interface ActionProps extends ButtonProps {
6
- /** 是否需要确认 */
6
+ /**
7
+ * 是否需要确认:true 时点击只弹确认气泡(UPopConfirm),点气泡里的「确认」才触发 `run`
8
+ * @default false
9
+ * @description 不要在本项的 `run` 回调里再调 `messageConfirm`:`run` 触发时气泡已经确认过一次,
10
+ * 会变成两次确认。需要弹窗级确认时去掉 `needConfirm`,只在 `run` 里用 `messageConfirm`
11
+ */
7
12
  needConfirm?: boolean;
8
13
  /**
9
14
  * 是否始终位于下拉菜单中,无视 `max` 限制
@@ -99,6 +99,8 @@ interface BatchEditProps extends TableProps {
99
99
  }
100
100
  /** 批量编辑组件定义的事件 */
101
101
  interface BatchEditEmits extends TableEmits {
102
+ /** 表单控件 change,透传自 UForm */
103
+ (e: 'field:change', field: string, ...args: any[]): void;
102
104
  /** 更新数据 */
103
105
  (e: 'update:data', value: Record<string, any>[]): void;
104
106
  /** 点击底部「新增一行」 */
@@ -3,7 +3,11 @@ import { ColorType, ComponentProps, DeconstructValue } from "@veltra/utils";
3
3
  //#region src/types/button.d.ts
4
4
  /** 按钮类型 */
5
5
  type ButtonType = ColorType;
6
- /** 按钮属性类型 */
6
+ /**
7
+ * 按钮属性类型
8
+ * @description 组件不注入默认 `aria-label`:可访问名来自默认插槽文本,
9
+ * 或你自己传入的 `aria-label`(透传到根元素);只有图标的按钮必须自行传 `aria-label`
10
+ */
7
11
  interface ButtonProps extends ComponentProps {
8
12
  /** 按钮类型 */
9
13
  type?: ButtonType;
@@ -12,15 +12,18 @@ interface DrawerProps {
12
12
  direction?: DrawerDirection;
13
13
  /** 是否显示关闭按钮 */
14
14
  showClose?: boolean;
15
- /** 抽屉标题 */
15
+ /**
16
+ * 抽屉标题;传入时在内容区上方渲染标题栏
17
+ * @description 不传则不渲染标题栏,内容仍全部来自默认插槽
18
+ */
16
19
  title?: string;
17
20
  }
18
21
  /** 抽屉组件定义的事件 */
19
22
  interface DrawerEmits {
20
23
  (e: 'update:modelValue', value: boolean): void;
21
- /** 关闭时触发 */
24
+ /** 开始关闭时触发(点遮罩、点关闭按钮) */
22
25
  (e: 'close'): void;
23
- /** 完全关闭后触发 */
26
+ /** 完全关闭后触发:抽屉与遮罩的退出动画结束、节点已移除时 */
24
27
  (e: 'closed'): void;
25
28
  }
26
29
  /** 抽屉组件暴露的属性和方法(组件内部使用) */
@@ -8,7 +8,10 @@ interface FormItemProps extends FormComponentProps {
8
8
  labelPosition?: 'top' | 'left';
9
9
  }
10
10
  /** 组件项组件定义的事件 */
11
- interface FormItemEmits {}
11
+ interface FormItemEmits {
12
+ /** 内部控件 change,参数与控件一致 */
13
+ (e: 'change', ...args: any[]): void;
14
+ }
12
15
  /** 组件项组件暴露的属性和方法 */
13
16
  interface FormItemExposed {}
14
17
  //#endregion
@@ -10,6 +10,12 @@ interface FormProps extends ComponentProps {
10
10
  cols?: number;
11
11
  /** 表单数据 */
12
12
  model?: Record<string, any>;
13
+ /** 开启后,字段当前值与基准值不同时,在控件下方展示「变更前」 */
14
+ showModified?: boolean;
15
+ /** 「变更前」标签文案,默认「变更前:」 */
16
+ modifiedLabel?: string;
17
+ /** 变更前基准数据;未传时回退到 model 引用首次传入时的快照(与 reset 一致) */
18
+ initialModel?: Record<string, any>;
13
19
  /** 表单项label宽度 */
14
20
  labelWidth?: string | number;
15
21
  /** 表单项 label 位置 */
@@ -22,7 +28,10 @@ interface FormProps extends ComponentProps {
22
28
  disabled?: boolean;
23
29
  }
24
30
  interface FormEmits {
25
- (e: 'field:change', field: string, value: any): void;
31
+ /** 控件 change 事件,仅用户操作触发 */
32
+ (e: 'field:change', field: string, ...args: any[]): void;
33
+ /** model 字段值更新,含编程写入 */
34
+ (e: 'field:update', field: string, value: any): void;
26
35
  }
27
36
  interface _FormExposed {
28
37
  el: ShallowRef<HTMLElement | null | undefined>;
@@ -18,6 +18,8 @@ interface LayoutProps {
18
18
  * const cols = ['200px', '1fr']
19
19
  * ```
20
20
  * [fr是什么?](https://developer.mozilla.org/zh-CN/docs/Web/CSS/flex_value)
21
+ * @description 默认插槽的每个直接子元素按顺序占一列,用自定义组件包一层时该组件必须是单根元素;
22
+ * 子元素渲染出 0 个(`v-if` 为假)或多个根节点时会挤动后面的列,此时自行给子元素写 `grid-column`
21
23
  */
22
24
  cols?: string[] | string;
23
25
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veltra/desktop",
3
- "version": "1.7.9",
3
+ "version": "1.7.11",
4
4
  "homepage": "https://github.com/cabinet-fe/ultra-ui#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/cabinet-fe/ultra-ui/issues"
@@ -80,12 +80,12 @@
80
80
  "peerDependencies": {
81
81
  "@cat-kit/core": ">=1.2.1",
82
82
  "@cat-kit/fe": ">=1.2.1",
83
- "@veltra/compositions": "^1.7.9",
84
- "@veltra/directives": "^1.7.9",
83
+ "@veltra/compositions": "^1.7.11",
84
+ "@veltra/directives": "^1.7.11",
85
85
  "@veltra/icons": "^1.5.0",
86
- "@veltra/sheet-core": "^2.5.5",
87
- "@veltra/styles": "^1.7.9",
88
- "@veltra/utils": "^1.7.9",
86
+ "@veltra/sheet-core": "^2.5.6",
87
+ "@veltra/styles": "^1.7.11",
88
+ "@veltra/utils": "^1.7.11",
89
89
  "vue": ">=3.5.42"
90
90
  },
91
91
  "peerDependenciesMeta": {
@@ -30,6 +30,8 @@ function mountBatchEdit() {
30
30
  apiMethod: undefined as string | undefined
31
31
  })
32
32
 
33
+ const fieldChanges: { field: string; args: unknown[] }[] = []
34
+
33
35
  const app = createApp({
34
36
  render() {
35
37
  return h(
@@ -39,6 +41,9 @@ function mountBatchEdit() {
39
41
  'onUpdate:data': (value: any[]) => {
40
42
  data.value = value
41
43
  },
44
+ 'onField:change': (field: string, ...args: unknown[]) => {
45
+ fieldChanges.push({ field, args })
46
+ },
42
47
  columns,
43
48
  model,
44
49
  rowKey: 'id',
@@ -89,6 +94,7 @@ function mountBatchEdit() {
89
94
  host,
90
95
  data,
91
96
  model,
97
+ fieldChanges,
92
98
  unmount() {
93
99
  app.unmount()
94
100
  host.remove()
@@ -196,4 +202,136 @@ describe('UBatchEdit quick-edit 切换编辑行', () => {
196
202
 
197
203
  unmount()
198
204
  })
205
+
206
+ it('切换行回显不触发 field:change', async () => {
207
+ const { host, fieldChanges, unmount } = mountBatchEdit()
208
+
209
+ fieldChanges.length = 0
210
+ await clickRow(host, 0)
211
+ await clickRow(host, 1)
212
+
213
+ expect(fieldChanges).toHaveLength(0)
214
+
215
+ unmount()
216
+ })
217
+
218
+ it('用户变更 Select 触发 field:change', async () => {
219
+ const { host, fieldChanges, unmount } = mountBatchEdit()
220
+
221
+ await clickRow(host, 0)
222
+ fieldChanges.length = 0
223
+
224
+ const input = host.querySelector<HTMLInputElement>('.u-select input')
225
+ expect(input).toBeTruthy()
226
+ input!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
227
+ await nextTick()
228
+ await nextTick()
229
+
230
+ const option = [...document.body.querySelectorAll<HTMLElement>('.u-select__option')].find(
231
+ (el) => el.textContent === '接口'
232
+ )
233
+ expect(option).toBeTruthy()
234
+ option!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
235
+ await nextTick()
236
+ await nextTick()
237
+
238
+ expect(fieldChanges).toHaveLength(1)
239
+ expect(fieldChanges[0]).toEqual({ field: 'behavior', args: [{ label: '接口', value: 'api' }] })
240
+
241
+ unmount()
242
+ })
243
+
244
+ it('field:change 联动改其他字段不循环触发', async () => {
245
+ const host = document.createElement('div')
246
+ document.body.appendChild(host)
247
+
248
+ const columns = defineTableColumns([
249
+ { key: 'label', name: '名称' },
250
+ { key: 'behavior', name: '行为' }
251
+ ])
252
+
253
+ const data = ref([
254
+ { id: '1', label: '存草稿', behavior: 'common-resource', submitType: 'DRAFT' },
255
+ { id: '2', label: '调接口', behavior: 'api', submitType: 'SUBMIT', apiMethod: 'POST' }
256
+ ])
257
+
258
+ const model = reactive({
259
+ id: '',
260
+ label: '',
261
+ behavior: 'event',
262
+ submitType: undefined as string | undefined,
263
+ apiMethod: undefined as string | undefined
264
+ })
265
+
266
+ const fieldChanges: string[] = []
267
+
268
+ const app = createApp({
269
+ render() {
270
+ return h(
271
+ UBatchEdit,
272
+ {
273
+ data: data.value,
274
+ 'onUpdate:data': (value: any[]) => {
275
+ data.value = value
276
+ },
277
+ 'onField:change': (field: string) => {
278
+ fieldChanges.push(field)
279
+ if (field === 'behavior') {
280
+ model.submitType = undefined
281
+ }
282
+ },
283
+ columns,
284
+ model,
285
+ rowKey: 'id',
286
+ quickEdit: true
287
+ },
288
+ {
289
+ form: () => [
290
+ h(UInput, { field: 'label', label: '名称' }),
291
+ h(USelect, {
292
+ field: 'behavior',
293
+ label: '行为',
294
+ options: [
295
+ { label: '事件', value: 'event' },
296
+ { label: '通用资源', value: 'common-resource' },
297
+ { label: '接口', value: 'api' }
298
+ ]
299
+ }),
300
+ h(USelect, {
301
+ field: 'submitType',
302
+ label: '提交类型',
303
+ options: [
304
+ { label: 'DRAFT', value: 'DRAFT' },
305
+ { label: 'SUBMIT', value: 'SUBMIT' }
306
+ ]
307
+ })
308
+ ]
309
+ }
310
+ )
311
+ }
312
+ })
313
+
314
+ app.mount(host)
315
+
316
+ await clickRow(host, 0)
317
+ fieldChanges.length = 0
318
+
319
+ const input = host.querySelector<HTMLInputElement>('.u-select input')
320
+ input!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
321
+ await nextTick()
322
+ await nextTick()
323
+
324
+ const option = [...document.body.querySelectorAll<HTMLElement>('.u-select__option')].find(
325
+ (el) => el.textContent === '接口'
326
+ )
327
+ option!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
328
+ await nextTick()
329
+ await nextTick()
330
+
331
+ expect(fieldChanges).toEqual(['behavior'])
332
+ expect(model.submitType).toBeUndefined()
333
+
334
+ app.unmount()
335
+ host.remove()
336
+ })
199
337
  })
@@ -28,7 +28,8 @@
28
28
  :model="props.model"
29
29
  :readonly="props.readonly"
30
30
  :label-width="props.labelWidth"
31
- @field:change="handleFieldChange"
31
+ @field:update="handleFieldUpdate"
32
+ @field:change="handleFormFieldChange"
32
33
  >
33
34
  <slot
34
35
  v-bind="{
@@ -91,8 +92,17 @@ defineOptions({ name: 'UBatchEditForm' })
91
92
 
92
93
  const batchEditCtx = inject(BatchEditDIKey)!
93
94
 
94
- const { cls, props, state, handleClose, handleSave, staticFeatures, dynamicFeatures, syncing } =
95
- batchEditCtx
95
+ const {
96
+ cls,
97
+ props,
98
+ state,
99
+ emit,
100
+ handleClose,
101
+ handleSave,
102
+ staticFeatures,
103
+ dynamicFeatures,
104
+ syncing
105
+ } = batchEditCtx
96
106
 
97
107
  const focused = toRef(batchEditCtx, 'focused')
98
108
 
@@ -105,7 +115,7 @@ defineExpose<_FormExposed>({
105
115
  reset: () => formComponentRef.value?.reset()
106
116
  })
107
117
 
108
- function handleFieldChange(field: string, value: any) {
118
+ function handleFieldUpdate(field: string, value: any) {
109
119
  // 编程方式重置/回显期间不写回行数据,避免默认值污染源数据
110
120
  if (syncing.value) return
111
121
 
@@ -114,6 +124,10 @@ function handleFieldChange(field: string, value: any) {
114
124
  }
115
125
  }
116
126
 
127
+ function handleFormFieldChange(field: string, ...args: any[]) {
128
+ emit('field:change', field, ...args)
129
+ }
130
+
117
131
  const creatable = computed(() => {
118
132
  if (state.formActionType !== 'create') return false
119
133
  return staticFeatures.value.has('create') || dynamicFeatures.value.create?.(state.row)
@@ -46,7 +46,7 @@ export function useEditState(options: Options) {
46
46
 
47
47
  // 先重置回 model 初始快照,再同步回显行数据。
48
48
  // 同一 tick 内的连续写入会被表单字段 watcher 合并,
49
- // 重置产生的默认值不会触发 field:change 回写行数据
49
+ // 重置产生的默认值不会触发 field:update 回写行数据
50
50
  formRef.value?.reset()
51
51
 
52
52
  if (row) {
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createApp, h } from 'vue'
3
+
4
+ import UButton from '../button.vue'
5
+
6
+ function mountButton(props: Record<string, unknown> = {}, slot = '新建用户') {
7
+ const host = document.createElement('div')
8
+ document.body.appendChild(host)
9
+
10
+ const app = createApp({ render: () => h(UButton, props, () => slot) })
11
+ app.mount(host)
12
+
13
+ return {
14
+ host,
15
+ button: host.querySelector('button')!,
16
+ unmount() {
17
+ app.unmount()
18
+ host.remove()
19
+ }
20
+ }
21
+ }
22
+
23
+ describe('UButton 原生属性与可访问名', () => {
24
+ it('不注入默认 aria-label:可访问名来自默认插槽文本', () => {
25
+ const { button, unmount } = mountButton()
26
+
27
+ expect(button.getAttribute('aria-label')).toBeNull()
28
+ expect(button.textContent?.trim()).toBe('新建用户')
29
+
30
+ unmount()
31
+ })
32
+
33
+ it('自己传的 aria-label 透传到根元素,图标按钮仍可命名', () => {
34
+ const { button, unmount } = mountButton({ 'aria-label': '编辑' }, '')
35
+
36
+ expect(button.getAttribute('aria-label')).toBe('编辑')
37
+
38
+ unmount()
39
+ })
40
+
41
+ it('原生 type 固定为 button,放进 form 里不会触发提交', () => {
42
+ const { button, unmount } = mountButton()
43
+
44
+ expect(button.getAttribute('type')).toBe('button')
45
+
46
+ unmount()
47
+ })
48
+ })
@@ -1,12 +1,5 @@
1
1
  <template>
2
- <button
3
- :class="classList"
4
- type="button"
5
- aria-label="button"
6
- ref="buttonRef"
7
- v-ripple="ripple"
8
- @click="handleClick"
9
- >
2
+ <button :class="classList" type="button" ref="buttonRef" v-ripple="ripple" @click="handleClick">
10
3
  <!-- 加载图标 -->
11
4
  <u-icon v-if="loading" :class="[bem.is('loading')]" :size="iconSize">
12
5
  <component :is="loadingIcon" />
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createApp, h, nextTick, ref } from 'vue'
3
+
4
+ import UDrawer from '../drawer.vue'
5
+
6
+ function mountDrawer(
7
+ props: Record<string, unknown> = {},
8
+ handlers: { onClose?: () => void; onClosed?: () => void } = {}
9
+ ) {
10
+ const host = document.createElement('div')
11
+ document.body.appendChild(host)
12
+
13
+ const model = ref(false)
14
+ const closedCount = ref(0)
15
+ const app = createApp({
16
+ render: () =>
17
+ h(
18
+ UDrawer,
19
+ {
20
+ ...props,
21
+ modelValue: model.value,
22
+ 'onUpdate:modelValue': (value: boolean) => {
23
+ model.value = value
24
+ },
25
+ onClose: () => handlers.onClose?.(),
26
+ onClosed: () => {
27
+ closedCount.value += 1
28
+ handlers.onClosed?.()
29
+ }
30
+ },
31
+ () => '抽屉内容'
32
+ )
33
+ })
34
+ app.mount(host)
35
+
36
+ return {
37
+ host,
38
+ model,
39
+ closedCount,
40
+ panel: () => document.body.querySelector<HTMLElement>('.u-drawer'),
41
+ overlay: () => document.body.querySelector<HTMLElement>('.u-drawer-overlay'),
42
+ unmount() {
43
+ app.unmount()
44
+ host.remove()
45
+ }
46
+ }
47
+ }
48
+
49
+ async function openDrawer(ctx: ReturnType<typeof mountDrawer>) {
50
+ ctx.model.value = true
51
+ await vi.waitFor(() => {
52
+ expect(ctx.panel()).toBeTruthy()
53
+ })
54
+ }
55
+
56
+ describe('UDrawer title', () => {
57
+ it('传入 title 时渲染标题栏', async () => {
58
+ const ctx = mountDrawer({ title: '用户详情' })
59
+ await openDrawer(ctx)
60
+
61
+ expect(ctx.panel()!.querySelector('.u-drawer__title')?.textContent).toBe('用户详情')
62
+
63
+ ctx.unmount()
64
+ })
65
+
66
+ it('不传 title 时不渲染标题栏', async () => {
67
+ const ctx = mountDrawer()
68
+ await openDrawer(ctx)
69
+
70
+ expect(ctx.panel()!.querySelector('.u-drawer__header')).toBeNull()
71
+
72
+ ctx.unmount()
73
+ })
74
+ })
75
+
76
+ describe('UDrawer closed', () => {
77
+ it('关闭动画结束后触发 closed,且晚于 close', async () => {
78
+ const order: string[] = []
79
+ const ctx = mountDrawer(
80
+ { showClose: true },
81
+ { onClose: () => order.push('close'), onClosed: () => order.push('closed') }
82
+ )
83
+ await openDrawer(ctx)
84
+
85
+ ctx.panel()!.querySelector<HTMLElement>('.u-drawer__close')!.click()
86
+ await vi.waitFor(() => {
87
+ expect(order).toContain('closed')
88
+ })
89
+
90
+ expect(order).toEqual(['close', 'closed'])
91
+ // 完全关闭后遮罩与抽屉都已移除
92
+ expect(ctx.overlay()).toBeNull()
93
+
94
+ ctx.unmount()
95
+ })
96
+
97
+ it('点遮罩关闭也触发 closed', async () => {
98
+ const ctx = mountDrawer()
99
+ await openDrawer(ctx)
100
+
101
+ ctx.overlay()!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
102
+ await vi.waitFor(() => {
103
+ expect(ctx.closedCount.value).toBe(1)
104
+ })
105
+
106
+ ctx.unmount()
107
+ })
108
+
109
+ it('从未打开时不触发 closed', async () => {
110
+ const ctx = mountDrawer()
111
+ await nextTick()
112
+ await nextTick()
113
+
114
+ expect(ctx.closedCount.value).toBe(0)
115
+
116
+ ctx.unmount()
117
+ })
118
+ })