@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 +2 -2
- package/src/components/basic/LayerView/Layer.vue +71 -1
- package/src/components/display/Image.vue +115 -1
- package/src/components/form/Input.vue +7 -1
- package/src/components/form/MoreSelect.vue +2 -1
- package/src/components/form/SearchMoreSelect.vue +2 -1
- package/src/components/form/SearchSelect.vue +2 -1
- package/src/components/form/Select.vue +2 -1
- package/src/components/form/optionSearch.ts +31 -0
- package/src/components/loader/FormLoader/FormLoader.vue +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neatui/vue",
|
|
3
|
-
"version": "4.1
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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="
|
|
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 = {}) => {
|