@neatui/vue 4.8.2 → 5.1.0

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.
@@ -114,7 +114,7 @@
114
114
  other?: {
115
115
  value: string;
116
116
  field?: string;
117
- config?: object;
117
+ config?: Record<string, any>;
118
118
  };
119
119
  otherValue?: string;
120
120
  tools?: boolean;
@@ -235,15 +235,20 @@
235
235
  };
236
236
  }
237
237
 
238
+ // 值不在当前选项中(选项未加载、分页或远程搜索)时显示原始值,避免看起来像未选择
239
+ if (props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== '') {
240
+ return { label: String(props.modelValue), value: props.modelValue };
241
+ }
242
+
238
243
  return {};
239
244
  }),
240
- displayLabel: computed(() => {
245
+ displayLabel: computed((): string => {
241
246
  return state.selectd.label || '';
242
247
  }),
243
- availableCount: computed(() => {
248
+ availableCount: computed((): number => {
244
249
  return state.filter?.filter((item: any) => !item.disabled)?.length || 0;
245
250
  }),
246
- filter: computed(() => {
251
+ filter: computed((): any[] => {
247
252
  let filteredOptions = props.options || [];
248
253
 
249
254
  if (state.search) {
@@ -285,35 +290,24 @@
285
290
  { immediate: true },
286
291
  );
287
292
 
288
- // 监听并验证modelValue的有效性
293
+ // 值是否在选项中(含 all 选项)
294
+ const inOptions = (value: any, options: any[] | undefined, allOption: any) =>
295
+ (allOption && value === allOption.value) || !!options?.some((option: any) => `${option.value}` === `${value}`);
296
+
297
+ // 只在选项变化使原本有效的值失效时清空(如级联切换上级后)。
298
+ // 值由外部写入(加载记录)、选项异步未到、分页或远程搜索尚未包含当前值时保留,避免丢失数据。
289
299
  watch(
290
300
  [() => props.modelValue, () => props.options, () => props.all, () => props.other],
291
- ([value, options, allOption, otherConfig]) => {
301
+ ([value, options, allOption, otherConfig], [previousValue, previousOptions]) => {
292
302
  // 如果配置了 other,则允许任意值(可能是用户自定义输入)
293
- if (otherConfig) {
294
- return;
295
- }
296
-
297
- // 如果有值但不在有效选项中,清空该值
298
- if (value != null && value !== '' && options?.length > 0) {
299
- let isValid = false;
300
-
301
- // 检查是否是 all 选项
302
- if (allOption && value === allOption.value) {
303
- isValid = true;
304
- } else {
305
- // 检查是否在普通选项中
306
- isValid = options.some((option: any) => `${option.value}` === `${value}`);
307
- }
308
-
309
- if (!isValid) {
310
- nextTick(() => {
311
- change(null);
312
- });
313
- }
303
+ if (otherConfig || value == null || value === '' || !options?.length) return;
304
+ const invalidated = value === previousValue && options !== previousOptions && inOptions(value, previousOptions, allOption) && !inOptions(value, options, allOption);
305
+ if (invalidated) {
306
+ nextTick(() => {
307
+ change(null);
308
+ });
314
309
  }
315
310
  },
316
- { immediate: true },
317
311
  );
318
312
 
319
313
  // 选择
@@ -28,6 +28,7 @@
28
28
  </template>
29
29
 
30
30
  <script setup lang="ts">
31
+ import type { PropType } from 'vue';
31
32
  import { reactive, ref, computed } from 'vue';
32
33
  import Input from './Input.vue';
33
34
  import { IPickerView, IScrollView } from '../basic';
@@ -36,7 +37,7 @@
36
37
  const emits = defineEmits(['update:modelValue']);
37
38
  const props = defineProps({
38
39
  theme: { type: String, default: '@a' },
39
- sz: { type: String, default: 'm' },
40
+ sz: { type: String as PropType<'' | 'xs' | 's' | 'm' | 'l' | 'xl'>, default: 'm' },
40
41
  co: { type: String, default: '' },
41
42
  block: { type: Boolean, default: true },
42
43
  tips: { type: String, default: '' },
@@ -50,7 +51,7 @@
50
51
  default: '',
51
52
  },
52
53
  rules: {
53
- type: Array,
54
+ type: Array as PropType<object[]>,
54
55
  default: () => [],
55
56
  },
56
57
  });
@@ -44,8 +44,91 @@
44
44
  </div>
45
45
  </template>
46
46
 
47
+ <script lang="ts">
48
+ import { nextTick } from 'vue';
49
+
50
+ interface MeasurePlan {
51
+ hold?: () => void;
52
+ collapse: () => void;
53
+ read: () => void;
54
+ apply: () => void;
55
+ }
56
+
57
+ /**
58
+ * 所有 Textarea 共用的测量批处理。
59
+ * 逐个实例「写 height → 读 scrollHeight → 写 height」会让每个实例都强制重排一次整页,
60
+ * 表格里上千个单元格就是上千次重排。这里同一轮先全部塌缩、再全部读取、最后全部落高,
61
+ * 整批只触发一两次布局计算。塌缩前先锁住外框高度,测量期间页面其余部分不动,也不会因容器变矮丢失滚动位置。
62
+ */
63
+ const pendingMeasures = new Set<() => MeasurePlan | null>();
64
+ let measureFlushQueued = false;
65
+
66
+ const flushMeasures = () => {
67
+ measureFlushQueued = false;
68
+ const plans: MeasurePlan[] = [];
69
+ pendingMeasures.forEach((createPlan) => {
70
+ const plan = createPlan();
71
+ if (plan) plans.push(plan);
72
+ });
73
+ pendingMeasures.clear();
74
+ plans.forEach((plan) => plan.hold?.());
75
+ plans.forEach((plan) => plan.collapse());
76
+ plans.forEach((plan) => plan.read());
77
+ plans.forEach((plan) => plan.apply());
78
+ };
79
+
80
+ const queueMeasure = (createPlan: () => MeasurePlan | null) => {
81
+ pendingMeasures.add(createPlan);
82
+ if (measureFlushQueued) return;
83
+ measureFlushQueued = true;
84
+ nextTick(flushMeasures);
85
+ };
86
+
87
+ const cancelMeasure = (createPlan: () => MeasurePlan | null) => {
88
+ pendingMeasures.delete(createPlan);
89
+ };
90
+
91
+ /**
92
+ * 共用一个 ResizeObserver。宽度取自回调条目本身,不调用 getBoundingClientRect,
93
+ * 挂载时也不预读宽度,避免每个实例各自强制布局。
94
+ */
95
+ const widthWatchers = new WeakMap<Element, { width: number | null; onChange: () => void }>();
96
+ let sharedResizeObserver: ResizeObserver | undefined;
97
+
98
+ const entryWidth = (entry: ResizeObserverEntry) => {
99
+ const box = entry.borderBoxSize as ResizeObserverSize | readonly ResizeObserverSize[] | undefined;
100
+ const size = Array.isArray(box) ? box[0] : box;
101
+ return size && typeof (size as ResizeObserverSize).inlineSize === 'number'
102
+ ? (size as ResizeObserverSize).inlineSize
103
+ : entry.contentRect.width;
104
+ };
105
+
106
+ const observeWidth = (node: Element, onChange: () => void) => {
107
+ if (typeof ResizeObserver === 'undefined') return;
108
+ sharedResizeObserver ||= new ResizeObserver((entries) => {
109
+ entries.forEach((entry) => {
110
+ const watcher = widthWatchers.get(entry.target);
111
+ if (!watcher) return;
112
+ const width = entryWidth(entry);
113
+ // 首次回调也测一次:挂载后到首次布局之间宽度可能已变(如表格列宽在单元格全部挂载后才确定)
114
+ if (watcher.width === null || Math.abs(width - watcher.width) > 0.5) {
115
+ watcher.width = width;
116
+ watcher.onChange();
117
+ }
118
+ });
119
+ });
120
+ widthWatchers.set(node, { width: null, onChange });
121
+ sharedResizeObserver.observe(node);
122
+ };
123
+
124
+ const unobserveWidth = (node: Element) => {
125
+ widthWatchers.delete(node);
126
+ sharedResizeObserver?.unobserve(node);
127
+ };
128
+ </script>
129
+
47
130
  <script setup lang="ts">
48
- import { reactive, watch, ref, nextTick, onMounted, onBeforeUnmount, computed } from 'vue';
131
+ import { reactive, watch, ref, onMounted, onBeforeUnmount, computed } from 'vue';
49
132
  import { isObject, isFunction } from '@fekit/utils';
50
133
 
51
134
  const emits = defineEmits(['update:modelValue', 'click', 'blur', 'focus', 'change', 'input', 'clear']);
@@ -71,6 +154,7 @@
71
154
  * prefix 前缀内容
72
155
  * suffix 后缀内容
73
156
  * interacted 用户是否交互
157
+ * emptyValue 清空或删空内容时回传的值,默认 null;字符串 v-model 可传 ''
74
158
  * autoHeight 是否自动调整高度;同时传入 minRows 与 maxRows 时默认开启;单独开启时无限增高
75
159
  * rows 初始行数(未开启自动高度,或开启但未同时设 min/max 时)
76
160
  * minRows 与 maxRows 同时传入时生效的最小行数
@@ -97,6 +181,7 @@
97
181
  prefix?: Array<any>;
98
182
  suffix?: Array<any>;
99
183
  interacted?: boolean | number | null;
184
+ emptyValue?: null | string;
100
185
  autoHeight?: boolean | null;
101
186
  rows?: number;
102
187
  minRows?: number;
@@ -121,6 +206,7 @@
121
206
  prefix: () => [],
122
207
  suffix: () => [],
123
208
  interacted: null,
209
+ emptyValue: null,
124
210
  // Boolean 未传时 Vue 会落到 false,无法与「显式关闭」区分
125
211
  autoHeight: null,
126
212
  // textarea 特有属性默认值
@@ -152,58 +238,93 @@
152
238
  // 有 min/max 时从最小行起算;仅 autoHeight 时保留 rows,并无限增高
153
239
  const displayRows = computed(() => (isAutoHeight.value && hasRowRange.value ? resolvedMinRows.value : props.rows));
154
240
 
155
- let measurementQueued = false;
156
- let resizeObserver: ResizeObserver | undefined;
157
- let observedWidth = 0;
158
-
159
- const fMeasureHeight = () => {
160
- measurementQueued = false;
241
+ // 测量拆成「塌缩 → 读取 → 落高」三步,交给模块级批处理统一调度(见文件末尾 queueMeasure)
242
+ const fMeasurePlan = (): MeasurePlan | null => {
161
243
  const node = el.value;
162
- if (!node) return;
244
+ if (!node) return null;
163
245
 
164
246
  if (!isAutoHeight.value) {
165
- node.style.height = '';
166
- node.style.overflowY = '';
167
- isOverflowing.value = false;
168
- return;
247
+ return {
248
+ collapse() {
249
+ node.style.height = '';
250
+ node.style.overflowY = '';
251
+ },
252
+ read() {},
253
+ apply() {
254
+ isOverflowing.value = false;
255
+ },
256
+ };
169
257
  }
170
258
 
259
+ // 塌缩期间把外框锁在当前高度,落高后还原
260
+ const frame = node.parentElement;
261
+ const frameMinHeight = frame?.style.minHeight || '';
262
+ let frameHeight = 0;
263
+ const hold = () => {
264
+ frameHeight = frame?.offsetHeight || 0;
265
+ };
266
+ const lockFrame = () => {
267
+ if (frame && frameHeight) frame.style.minHeight = `${frameHeight}px`;
268
+ };
269
+ const releaseFrame = () => {
270
+ if (frame && frameHeight) frame.style.minHeight = frameMinHeight;
271
+ };
272
+
171
273
  // 未同时设 min/max:跟内容无限增高,不封顶
172
274
  if (!hasRowRange.value) {
173
- node.style.height = 'auto';
174
- node.style.overflowY = 'hidden';
175
- node.style.height = `${node.scrollHeight + 1}px`;
176
- isOverflowing.value = false;
177
- return;
275
+ let contentHeight = 0;
276
+ return {
277
+ hold,
278
+ collapse() {
279
+ lockFrame();
280
+ node.style.height = 'auto';
281
+ node.style.overflowY = 'hidden';
282
+ },
283
+ read() {
284
+ contentHeight = node.scrollHeight;
285
+ },
286
+ apply() {
287
+ node.style.height = `${contentHeight + 1}px`;
288
+ releaseFrame();
289
+ isOverflowing.value = false;
290
+ },
291
+ };
178
292
  }
179
293
 
180
- node.style.height = '0px';
181
- node.style.overflowY = 'hidden';
182
-
183
- const style = getComputedStyle(node);
184
- let lineHeight = parseFloat(style.lineHeight);
185
- if (!Number.isFinite(lineHeight) || lineHeight <= 0) {
186
- lineHeight = (parseFloat(style.fontSize) || 14) * 1.5;
187
- }
294
+ let targetHeight = 0;
295
+ let overflowing = false;
296
+ return {
297
+ hold,
298
+ collapse() {
299
+ lockFrame();
300
+ node.style.height = '0px';
301
+ node.style.overflowY = 'hidden';
302
+ },
303
+ read() {
304
+ const style = getComputedStyle(node);
305
+ let lineHeight = parseFloat(style.lineHeight);
306
+ if (!Number.isFinite(lineHeight) || lineHeight <= 0) {
307
+ lineHeight = (parseFloat(style.fontSize) || 14) * 1.5;
308
+ }
188
309
 
189
- const paddingBlock = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0);
190
- const minHeight = resolvedMinRows.value * lineHeight + paddingBlock;
191
- const maxHeight = resolvedMaxRows.value * lineHeight + paddingBlock;
192
- const contentHeight = node.scrollHeight;
193
- const overflowing = contentHeight > maxHeight + 1;
194
- const measuredHeight = contentHeight <= minHeight + 1 ? minHeight : contentHeight;
195
- const targetHeight = Math.min(Math.max(measuredHeight, minHeight), maxHeight);
196
-
197
- node.style.height = `${Math.ceil(targetHeight)}px`;
198
- node.style.overflowY = overflowing ? 'auto' : 'hidden';
199
- isOverflowing.value = overflowing;
310
+ const paddingBlock = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0);
311
+ const minHeight = resolvedMinRows.value * lineHeight + paddingBlock;
312
+ const maxHeight = resolvedMaxRows.value * lineHeight + paddingBlock;
313
+ const contentHeight = node.scrollHeight;
314
+ overflowing = contentHeight > maxHeight + 1;
315
+ const measuredHeight = contentHeight <= minHeight + 1 ? minHeight : contentHeight;
316
+ targetHeight = Math.min(Math.max(measuredHeight, minHeight), maxHeight);
317
+ },
318
+ apply() {
319
+ node.style.height = `${Math.ceil(targetHeight)}px`;
320
+ node.style.overflowY = overflowing ? 'auto' : 'hidden';
321
+ releaseFrame();
322
+ isOverflowing.value = overflowing;
323
+ },
324
+ };
200
325
  };
201
326
 
202
- const fAutoHeight = () => {
203
- if (measurementQueued) return;
204
- measurementQueued = true;
205
- nextTick(fMeasureHeight);
206
- };
327
+ const fAutoHeight = () => queueMeasure(fMeasurePlan);
207
328
 
208
329
  watch(
209
330
  [() => isAutoHeight.value, () => resolvedMinRows.value, () => resolvedMaxRows.value, () => props.modelValue, () => props.rows, () => props.sz, () => props.theme],
@@ -282,7 +403,7 @@
282
403
 
283
404
  // 清空
284
405
  function clear() {
285
- emits('update:modelValue', null);
406
+ emits('update:modelValue', props.emptyValue);
286
407
  emits('clear');
287
408
  }
288
409
  // 输入
@@ -302,7 +423,7 @@
302
423
  }
303
424
  // 输入事件
304
425
  emits('input', e, state);
305
- emits('update:modelValue', value || null);
426
+ emits('update:modelValue', value || props.emptyValue);
306
427
 
307
428
  // 自动高度
308
429
  fAutoHeight();
@@ -333,25 +454,16 @@
333
454
  return { ..._event };
334
455
  };
335
456
 
336
- // 组件挂载后初始化自动高度
457
+ // 组件挂载后初始化自动高度;宽度变化(换行数随之变化)时重新测量
337
458
  onMounted(() => {
338
459
  const node = el.value;
339
- if (node && typeof ResizeObserver !== 'undefined') {
340
- observedWidth = node.getBoundingClientRect().width;
341
- resizeObserver = new ResizeObserver(() => {
342
- const width = node.getBoundingClientRect().width;
343
- if (Math.abs(width - observedWidth) > 0.5) {
344
- observedWidth = width;
345
- fAutoHeight();
346
- }
347
- });
348
- resizeObserver.observe(node);
349
- }
460
+ if (node) observeWidth(node, fAutoHeight);
350
461
  fAutoHeight();
351
462
  });
352
463
 
353
464
  onBeforeUnmount(() => {
354
- resizeObserver?.disconnect();
355
- resizeObserver = undefined;
465
+ if (el.value) unobserveWidth(el.value);
466
+ cancelMeasure(fMeasurePlan);
356
467
  });
357
468
  </script>
469
+
@@ -1,11 +1,47 @@
1
1
  <template>
2
- <div></div>
2
+ <div :ui-form="`${theme} type:input ${disabled ? 'disabled' : ''}`">
3
+ <input type="time" :value="modelValue || ''" :min="min" :max="max" :step="step" :disabled="disabled" :readonly="readonly"
4
+ :aria-invalid="error ? true : undefined" :aria-label="label" :required="required" @input="update" @change="change" @blur="emit('blur', $event)" @focus="emit('focus', $event)" />
5
+ <span v-if="verify && error" role="alert" class="co-risk">{{ error }}</span>
6
+ </div>
3
7
  </template>
4
-
8
+ <script lang="ts">
9
+ export interface TimeConstraints { min?: string; max?: string; step?: number; required?: boolean }
10
+ const milliseconds = (value: unknown): number | null => {
11
+ const match = typeof value === 'string' && /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/.exec(value);
12
+ if (!match || +match[1] > 23 || +match[2] > 59 || +(match[3] || 0) > 59) return null;
13
+ return ((+match[1] * 60 + +match[2]) * 60 + +(match[3] || 0)) * 1000 + +(match[4] || '').padEnd(3, '0');
14
+ };
15
+ // 独立控件提示和 FormLoader 提交校验共用,不依赖浏览器语言或 DOM。
16
+ export const timeError = (value: unknown, options: TimeConstraints = {}): string | null => {
17
+ if (value === '' || value == null) return options.required ? '请选择时间' : null;
18
+ const time = milliseconds(value);
19
+ if (time === null) return '请输入有效时间';
20
+ const min = milliseconds(options.min), max = milliseconds(options.max);
21
+ const outside = min !== null && max !== null && min > max
22
+ ? time < min && time > max : (min !== null && time < min) || (max !== null && time > max);
23
+ if (outside) return `时间须在 ${options.min || '00:00'} 至 ${options.max || '23:59:59'} 之间`;
24
+ const step = Number.isFinite(options.step) && options.step! > 0 ? options.step! : 60;
25
+ if ((time - (min ?? 0)) % Math.round(step * 1000)) return `时间须按 ${step} 秒间隔选择`;
26
+ return null;
27
+ };
28
+ </script>
5
29
  <script setup lang="ts">
6
- interface Props {
7
- props?: any;
8
- }
9
-
10
- const props = withDefaults(defineProps<Props>(), {});
30
+ import { computed } from 'vue';
31
+ const props = withDefaults(defineProps<{
32
+ modelValue?: string | null; min?: string; max?: string; step?: number;
33
+ disabled?: boolean; readonly?: boolean; required?: boolean; label?: string; theme?: string; verify?: boolean;
34
+ }>(), { modelValue: '', step: 60, disabled: false, readonly: false, theme: '@a', verify: true });
35
+ const emit = defineEmits(['update:modelValue', 'change', 'invalid', 'blur', 'focus']);
36
+ const error = computed(() => timeError(props.modelValue, props));
37
+ const update = (event: Event) => {
38
+ if (props.disabled || props.readonly) return;
39
+ const value = (event.target as HTMLInputElement).value;
40
+ emit('update:modelValue', value);
41
+ const message = timeError(value, props);
42
+ if (message) emit('invalid', message);
43
+ };
44
+ const change = (event: Event) => {
45
+ if (!props.disabled && !props.readonly) emit('change', (event.target as HTMLInputElement).value);
46
+ };
11
47
  </script>
@@ -203,7 +203,7 @@
203
203
  uploadStyle?: string;
204
204
  tips?: string;
205
205
  accept?: string | 'image/*' | 'video/*' | 'audio/*';
206
- upload?: (formData: FormData) => Promise<any>;
206
+ upload?: (formData: FormData, fileExtension?: string) => Promise<any>;
207
207
  maxSize?: number;
208
208
  compress?: boolean;
209
209
  disabled?: boolean;
@@ -1,3 +1,5 @@
1
+ import Radio from './Radio.vue';
2
+ import Gallery from './Gallery.vue';
1
3
  import Input from './Input.vue';
2
4
  import InputRange from './InputRange.vue';
3
5
  import Switch from './Switch.vue';
@@ -30,6 +32,9 @@ import Upload from './Upload.vue';
30
32
  import Cascader from './Cascader.vue';
31
33
 
32
34
  export {
35
+ Radio,
36
+ Gallery,
37
+ Gallery as SelectImg,
33
38
  Input,
34
39
  InputRange,
35
40
  Switch,