@neatui/vue 4.1.0 → 4.2.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neatui/vue",
3
- "version": "4.1.0",
3
+ "version": "4.2.1",
4
4
  "description": "basic component ui",
5
5
  "main": "./src/index.ts",
6
6
  "license": "MIT",
@@ -34,7 +34,7 @@
34
34
  "@fekit/scrollto": "^3.0.3",
35
35
  "@fekit/toast": "^2.7.2",
36
36
  "@fekit/utils": "^6.0.0",
37
- "@neatui/css": "^4.1.0",
37
+ "@neatui/css": "^4.2.1",
38
38
  "@rollup/plugin-commonjs": "^26.0.1",
39
39
  "@rollup/plugin-node-resolve": "^15.2.3",
40
40
  "@vue/compiler-sfc": "^3.4.30",
@@ -58,6 +58,8 @@
58
58
  };
59
59
 
60
60
  const SCROLL_LOCK_STATE = Symbol.for('@neatui/vue.Layer.scrollLock');
61
+ const ESCAPE_STACK = Symbol.for('@neatui/vue.Layer.escapeStack');
62
+ const ESCAPE_LISTENER = Symbol.for('@neatui/vue.Layer.escapeListener');
61
63
 
62
64
  const getScrollLockState = (): ScrollLockState => {
63
65
  if (!_tasks[SCROLL_LOCK_STATE]) {
@@ -71,6 +73,52 @@
71
73
  return _tasks[SCROLL_LOCK_STATE];
72
74
  };
73
75
 
76
+ const getEscapeStack = (): string[] => {
77
+ if (!_tasks[ESCAPE_STACK]) {
78
+ Object.defineProperty(_tasks, ESCAPE_STACK, {
79
+ configurable: true,
80
+ value: [] as string[],
81
+ });
82
+ }
83
+ return _tasks[ESCAPE_STACK];
84
+ };
85
+
86
+ const pushEscapeStack = (layerId: string) => {
87
+ if (!layerId) return;
88
+ const stack = getEscapeStack();
89
+ const index = stack.indexOf(layerId);
90
+ if (index !== -1) stack.splice(index, 1);
91
+ stack.push(layerId);
92
+ };
93
+
94
+ const removeEscapeStack = (layerId: string) => {
95
+ if (!layerId) return;
96
+ const stack = getEscapeStack();
97
+ const index = stack.indexOf(layerId);
98
+ if (index !== -1) stack.splice(index, 1);
99
+ };
100
+
101
+ // 全局只挂一次:ESC 仅关闭最后打开且已完全显示的一层
102
+ const ensureEscapeListener = () => {
103
+ if (!env.client || _tasks[ESCAPE_LISTENER]) return;
104
+ const onKeydown = (ev: KeyboardEvent) => {
105
+ if (ev.key !== 'Escape' || ev.defaultPrevented) return;
106
+ const stack = getEscapeStack();
107
+ const topId = stack[stack.length - 1];
108
+ if (!topId) return;
109
+ const layer = _tasks[topId];
110
+ // 禁止 ESC:显式 escClose=false,或禁止暗层关闭时同步禁 ESC
111
+ if (!layer || layer.view !== 3 || layer.escClose === false || layer.darkClickClose === false) return;
112
+ layer.hide();
113
+ ev.preventDefault();
114
+ };
115
+ document.addEventListener('keydown', onKeydown);
116
+ Object.defineProperty(_tasks, ESCAPE_LISTENER, {
117
+ configurable: true,
118
+ value: onKeydown,
119
+ });
120
+ };
121
+
74
122
  const hasInlineProperty = (style: CSSStyleDeclaration, property: string) => {
75
123
  for (let index = 0; index < style.length; index += 1) {
76
124
  if (style.item(index) === property) return true;
@@ -230,6 +278,7 @@
230
278
  flex?: string;
231
279
  dark?: string | number;
232
280
  darkClickClose?: boolean;
281
+ escClose?: boolean;
233
282
  wrapStyle?: string;
234
283
  }
235
284
 
@@ -242,6 +291,7 @@
242
291
  flex: 'cm',
243
292
  dark: '',
244
293
  darkClickClose: true,
294
+ escClose: true,
245
295
  wrapStyle: '',
246
296
  });
247
297
 
@@ -268,12 +318,30 @@
268
318
  if (_tasks[props.id]) {
269
319
  _tasks[props.id].view = view;
270
320
  }
321
+ if (view === 2 || view === 3) pushEscapeStack(props.id);
322
+ else removeEscapeStack(props.id);
271
323
  if (view > 0) acquireScrollLock(scrollLockOwner);
272
324
  else releaseScrollLock(scrollLockOwner);
273
325
  },
274
326
  { deep: true, immediate: true },
275
327
  );
276
328
 
329
+ watch(
330
+ () => props.escClose,
331
+ (escClose) => {
332
+ if (_tasks[props.id]) _tasks[props.id].escClose = escClose;
333
+ },
334
+ { immediate: true },
335
+ );
336
+
337
+ watch(
338
+ () => props.darkClickClose,
339
+ (darkClickClose) => {
340
+ if (_tasks[props.id]) _tasks[props.id].darkClickClose = darkClickClose;
341
+ },
342
+ { immediate: true },
343
+ );
344
+
277
345
  // 回调合集
278
346
  const on: any = {
279
347
  show: [],
@@ -367,13 +435,15 @@
367
435
  console.error('Layer 有重复 id', id);
368
436
  }
369
437
  if (id) {
370
- _tasks[id] = { init, show, hide, open, none };
438
+ _tasks[id] = { init, show, hide, open, none, view: state.view, escClose: props.escClose, darkClickClose: props.darkClickClose };
439
+ ensureEscapeListener();
371
440
  } else {
372
441
  console.error('Layer 必须填写全局统一的 id');
373
442
  }
374
443
 
375
444
  // 卸载弹层
376
445
  onBeforeUnmount(() => {
446
+ removeEscapeStack(id);
377
447
  releaseScrollLock(scrollLockOwner);
378
448
  delete _tasks[id];
379
449
  });
@@ -1,7 +1,16 @@
1
1
  <template>
2
2
  <div>
3
3
  <div :class="`mc-img ${imgClass}`" :style="imgStyle">
4
- <img :src="src[srcName]" :style="`object-fit: ${fit}`" @click.stop="showPreview" />
4
+ <img
5
+ ref="imgRef"
6
+ :src="displaySrc"
7
+ :alt="alt"
8
+ :loading="lazy ? 'lazy' : 'eager'"
9
+ :decoding="decoding"
10
+ :class="{ 'is-loaded': isLoaded }"
11
+ :style="`object-fit: ${fit}`"
12
+ @click.stop="showPreview"
13
+ />
5
14
  <div v-if="tools" class="mc-img-tools">
6
15
  <slot name="tools">
7
16
  <div class="mc-img-clean co-risk" @click="delImg"></div>
@@ -14,6 +23,7 @@
14
23
  </div>
15
24
  </template>
16
25
  <script setup lang="ts">
26
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
17
27
  import { Layer, LayerById } from '../basic';
18
28
  import PhotoViewer from './PhotoViewer.vue';
19
29
 
@@ -46,10 +56,108 @@
46
56
  type: Number,
47
57
  default: 0,
48
58
  },
59
+ // 是否开启懒加载
60
+ lazy: {
61
+ type: Boolean,
62
+ default: true,
63
+ },
64
+ // 懒加载占位图
65
+ placeholder: {
66
+ type: String,
67
+ default: '/assets/img/none.webp',
68
+ },
69
+ alt: {
70
+ type: String,
71
+ default: '',
72
+ },
73
+ decoding: {
74
+ type: String,
75
+ default: 'async',
76
+ },
77
+ // 提前加载边距,接近视口即触发(同 IntersectionObserver.rootMargin)
78
+ rootMargin: {
79
+ type: String,
80
+ default: '200px',
81
+ },
49
82
  });
50
83
 
51
84
  const emit = defineEmits(['delImg']);
52
85
  const layerId = Math.random().toString(36).slice(-6);
86
+ const imgRef = ref<HTMLImageElement | null>(null);
87
+ const isLoaded = ref(!props.lazy);
88
+ let observer: IntersectionObserver | null = null;
89
+
90
+ const realSrc = computed(() => props.src?.[props.srcName] || '');
91
+ const displaySrc = computed(() => {
92
+ if (!props.lazy || isLoaded.value) {
93
+ return realSrc.value || props.placeholder;
94
+ }
95
+ return props.placeholder;
96
+ });
97
+
98
+ const stopObserver = () => {
99
+ if (observer) {
100
+ observer.disconnect();
101
+ observer = null;
102
+ }
103
+ };
104
+
105
+ const startObserver = () => {
106
+ stopObserver();
107
+ if (!props.lazy || isLoaded.value) return;
108
+ const el = imgRef.value;
109
+ if (!el) {
110
+ isLoaded.value = true;
111
+ return;
112
+ }
113
+ if (typeof IntersectionObserver === 'undefined') {
114
+ isLoaded.value = true;
115
+ return;
116
+ }
117
+ observer = new IntersectionObserver(
118
+ (entries) => {
119
+ if (entries.some((entry) => entry.isIntersecting)) {
120
+ isLoaded.value = true;
121
+ stopObserver();
122
+ }
123
+ },
124
+ { rootMargin: props.rootMargin },
125
+ );
126
+ observer.observe(el);
127
+ };
128
+
129
+ onMounted(() => {
130
+ if (!props.lazy) {
131
+ isLoaded.value = true;
132
+ return;
133
+ }
134
+ startObserver();
135
+ });
136
+
137
+ watch(
138
+ () => props.lazy,
139
+ (lazy) => {
140
+ if (!lazy) {
141
+ isLoaded.value = true;
142
+ stopObserver();
143
+ return;
144
+ }
145
+ isLoaded.value = false;
146
+ startObserver();
147
+ },
148
+ );
149
+
150
+ watch(
151
+ () => props.rootMargin,
152
+ () => {
153
+ if (!props.lazy || isLoaded.value) return;
154
+ startObserver();
155
+ },
156
+ );
157
+
158
+ onBeforeUnmount(() => {
159
+ stopObserver();
160
+ });
53
161
 
54
162
  //预览
55
163
  const showPreview = () => {
@@ -77,6 +185,12 @@
77
185
  height: 100%;
78
186
  transform: translate3d(0, 0, 0);
79
187
  border-radius: 0.15em;
188
+ opacity: 0.55;
189
+ transition: opacity 0.25s ease;
190
+
191
+ &.is-loaded {
192
+ opacity: 1;
193
+ }
80
194
  }
81
195
 
82
196
  &-tools {
@@ -28,7 +28,11 @@
28
28
  />
29
29
  </slot>
30
30
  <div ui-form-suffix="">
31
- <i ui-form-clean="" v-if="clearable && props.modelValue && !readonly && !preview && !disabled" @click.stop="clear"></i>
31
+ <i
32
+ v-if="clearable && !readonly && !preview && !disabled"
33
+ :ui-form-clean="hasClearValue ? '' : 'empty'"
34
+ @click.stop="hasClearValue && clear()"
35
+ ></i>
32
36
  <div
33
37
  class="o-ls"
34
38
  v-if="type === 'password' && cipher && !preview"
@@ -127,6 +131,8 @@
127
131
  interacted: null,
128
132
  });
129
133
 
134
+ const hasClearValue = computed(() => props.modelValue !== '' && props.modelValue !== null && props.modelValue !== undefined);
135
+
130
136
  const state: any = reactive({
131
137
  // 用户是否交互
132
138
  interacted: null,
@@ -60,6 +60,7 @@
60
60
  import { reactive, ref, computed, watch, nextTick } from 'vue';
61
61
  import Input from './Input.vue';
62
62
  import { IFollowView } from '../basic';
63
+ import { matchOptionSearch } from './optionSearch';
63
64
  const ifollow: any = ref(null);
64
65
 
65
66
  const emits = defineEmits(['update:modelValue']);
@@ -177,7 +178,7 @@
177
178
  filter: computed(() => {
178
179
  if (state.search) {
179
180
  return props.options?.filter((item: any) => {
180
- return item.label?.includes(state.search);
181
+ return matchOptionSearch(item, state.search);
181
182
  });
182
183
  } else {
183
184
  return props.options;
@@ -46,6 +46,7 @@
46
46
  import { reactive, ref, computed } from 'vue';
47
47
  import Input from './Input.vue';
48
48
  import { IFollowView } from '../basic';
49
+ import { matchOptionSearch } from './optionSearch';
49
50
  const ifollow: any = ref(null);
50
51
 
51
52
  const emits = defineEmits(['update:modelValue', 'change', 'request', 'onshow', 'onhide']);
@@ -118,7 +119,7 @@
118
119
  filter: computed(() => {
119
120
  if (state.search) {
120
121
  return props.options?.filter((item) => {
121
- return item.label?.includes(state.search);
122
+ return matchOptionSearch(item, state.search);
122
123
  });
123
124
  } else {
124
125
  return props.options;
@@ -50,6 +50,7 @@
50
50
  import { reactive, ref, computed } from 'vue';
51
51
  import Input from './Input.vue';
52
52
  import { IFollowView } from '../basic';
53
+ import { matchOptionSearch } from './optionSearch';
53
54
  const ifollow: any = ref(null);
54
55
  console.warn('「SearchSelect」 即将废弃,建议使用「Select」 设置参数search为true,示例:<Select v-model="value" :search="true" />');
55
56
  const emits = defineEmits(['update:modelValue', 'change', 'onshow', 'onhide']);
@@ -161,7 +162,7 @@
161
162
  filter: computed(() => {
162
163
  if (state.search) {
163
164
  return props.options?.filter((item: any) => {
164
- return item.label?.includes(state.search);
165
+ return matchOptionSearch(item, state.search);
165
166
  });
166
167
  } else {
167
168
  return props.options;
@@ -68,6 +68,7 @@
68
68
  import { reactive, ref, computed, watch, nextTick } from 'vue';
69
69
  import Input from './Input.vue';
70
70
  import { IFollowView } from '../basic';
71
+ import { matchOptionSearch } from './optionSearch';
71
72
  const ifollow: any = ref(null);
72
73
  const customInputRef: any = ref(null);
73
74
 
@@ -246,7 +247,7 @@
246
247
 
247
248
  if (state.search) {
248
249
  filteredOptions = filteredOptions.filter((item: any) => {
249
- return item.label?.includes(state.search);
250
+ return matchOptionSearch(item, state.search);
250
251
  });
251
252
  }
252
253
 
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 选项搜索:匹配 label / value,以及选项对象上可遍历的全部字段(含嵌套)
3
+ * 大小写不敏感
4
+ */
5
+ export const matchOptionSearch = (item: any, keyword: unknown): boolean => {
6
+ const q = String(keyword ?? '')
7
+ .trim()
8
+ .toLowerCase();
9
+ if (!q) return true;
10
+ if (item == null) return false;
11
+
12
+ if (typeof item !== 'object') {
13
+ return String(item).toLowerCase().includes(q);
14
+ }
15
+
16
+ const seen = new WeakSet<object>();
17
+ const walk = (val: any): boolean => {
18
+ if (val == null) return false;
19
+ const t = typeof val;
20
+ if (t === 'string' || t === 'number' || t === 'boolean' || t === 'bigint') {
21
+ return String(val).toLowerCase().includes(q);
22
+ }
23
+ if (t !== 'object') return false;
24
+ if (seen.has(val)) return false;
25
+ seen.add(val);
26
+ if (Array.isArray(val)) return val.some(walk);
27
+ return Object.values(val).some(walk);
28
+ };
29
+
30
+ return walk(item);
31
+ };
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div :ui-row="rows" class="n-sl-sub">
2
+ <div :ui-row="rows" :class="rootClass">
3
3
  <template v-for="(col, idx) in state.form" :key="idx">
4
4
  <div v-if="!col.clear" :id="col._fid" :ui-col="col.media" :ui-flex="`${vertical ? 'col xm' : 'row lt'}`">
5
5
  <template v-if="col.label">
@@ -182,7 +182,7 @@
182
182
  </div>
183
183
  </template>
184
184
  <script lang="ts" setup>
185
- import { watch, reactive, onBeforeUnmount, nextTick } from 'vue';
185
+ import { watch, reactive, onBeforeUnmount, nextTick, useAttrs, computed } from 'vue';
186
186
  import { useRoute, useRouter } from 'vue-router';
187
187
  import { isString, isObject, isFunction, deepcopy, isArray } from '@fekit/utils';
188
188
  import {
@@ -212,6 +212,9 @@
212
212
 
213
213
  const route = useRoute();
214
214
  const router = useRouter();
215
+ const attrs = useAttrs();
216
+ // 外部写了 class 时去掉默认根 class,由外部接管
217
+ const rootClass = computed(() => (attrs.class ? undefined : 'n-sl-sub'));
215
218
 
216
219
  // 计算Label行高
217
220
  const clh = ({ sz = '' }: any = {}) => {