@neatui/vue 5.1.11 → 5.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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# @neatui/vue 更新记录
|
|
2
2
|
|
|
3
|
+
## 5.2.1
|
|
4
|
+
|
|
5
|
+
### IFollowView(移动端底部面板)
|
|
6
|
+
- 面板改为从底部滑入滑出,暗层同步淡入淡出,使用 `cubic-bezier(0.32, 0.72, 0, 1)` 减速曲线;支持“减少动态效果”。
|
|
7
|
+
- 面板通过 Popover API 进入浏览器 top layer,不再依赖 z-index;不支持时回退为普通 fixed。
|
|
8
|
+
- 插槽内容撑满面板宽度,覆盖桌面端写死的宽度。
|
|
9
|
+
- 面板贴底,Home 指示条安全区以面板底色留白(需页面 viewport 设置 `viewport-fit=cover`)。
|
|
10
|
+
- 顶部圆角改为 `0.75em`;上方间距统一由标题栏提供;关闭按钮改用 CSS 图标 `ui-icon="close"`。
|
|
11
|
+
- 移动端样式只作用于本层面板盒子,不再影响插槽内其他 `ui-tips` 结构。
|
|
12
|
+
|
|
13
|
+
### 依赖
|
|
14
|
+
- CSS 依赖更新为 `@neatui/css@^5.2.1`。
|
|
15
|
+
|
|
16
|
+
## 5.2.0
|
|
17
|
+
|
|
18
|
+
### Layer
|
|
19
|
+
- 新增 `on.cancel` 回调,修正 show / hide 回调的语义。
|
|
20
|
+
- 加强注册逻辑;动画失败时有兜底;点击遮罩关闭更可靠。
|
|
21
|
+
- 离开分组时释放遮罩;校验挂载区域的容器。
|
|
22
|
+
- 排队中的弹层在同一时刻先 show 再 hide 时,仍按队列规则处理;任意 hide 都会丢弃排队中的请求。
|
|
23
|
+
- 关闭或立即挂起后,下层弹层能正确恢复。
|
|
24
|
+
|
|
25
|
+
### IFollowView
|
|
26
|
+
- 重写点击外部关闭的逻辑;同步嵌套浮层、`v-model` 和 hover 的状态。
|
|
27
|
+
- 挂载时就是打开状态的,也会初始化定位。
|
|
28
|
+
- 手动切换开关时取消 hover 计时器;鼠标在嵌套子浮层上时父浮层保持打开。
|
|
29
|
+
- 释放已失效的 Follow 实例。
|
|
30
|
+
|
|
31
|
+
### Select
|
|
32
|
+
- 选中“其他”后立即显示输入框,有独立字段时也不再要求先有值;输入框会自动聚焦。
|
|
33
|
+
- 回车和失焦确认不再重复触发 `change`。
|
|
34
|
+
- 有独立字段时,空输入会保留已选的“其他”,等待补填。
|
|
35
|
+
- 新增 `footer` 插槽,可通过插槽参数拿到 `close` 方法。
|
|
36
|
+
|
|
37
|
+
### DateView
|
|
38
|
+
- 手机端宽度撑满。
|
|
39
|
+
|
|
40
|
+
### 依赖
|
|
41
|
+
- CSS 依赖更新为 `@neatui/css@^5.2.0`。Grid 间距的不兼容变更见 5.1.11 一节。
|
|
42
|
+
|
|
3
43
|
## 5.1.11
|
|
4
44
|
|
|
5
45
|
- FormLoader 新增 `mode="read"`:保留表单控件,统一设置 `disabled`;禁止字段写回、数组增删和排序,隐藏校验提示。插槽的 `bind.disabled` 同步为 `true`,自定义控件需绑定该属性。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neatui/vue",
|
|
3
|
-
"version": "5.1
|
|
3
|
+
"version": "5.2.1",
|
|
4
4
|
"description": "basic component ui",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"@fekit/scrollto": "^3.0.3",
|
|
37
37
|
"@fekit/toast": "^2.7.2",
|
|
38
38
|
"@fekit/utils": "^6.0.0",
|
|
39
|
-
"@neatui/css": "^5.1
|
|
39
|
+
"@neatui/css": "^5.2.1",
|
|
40
40
|
"@rollup/plugin-commonjs": "^26.0.1",
|
|
41
41
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
42
42
|
"@vue/compiler-sfc": "^3.4.30",
|
|
@@ -12,19 +12,19 @@
|
|
|
12
12
|
:style="`position: fixed; pointer-events: none; ${tipStyle}`"
|
|
13
13
|
:ui-tips="uiTips"
|
|
14
14
|
:ui-tips-view="state.show ? 1 : 0"
|
|
15
|
-
@mouseover="
|
|
16
|
-
@mouseout="
|
|
15
|
+
@mouseover="tipHover(1)"
|
|
16
|
+
@mouseout="tipHover(0)"
|
|
17
17
|
@click="clickhide"
|
|
18
18
|
>
|
|
19
|
-
<div ref="box" :ui-tips-box="state.pos" :class="tipBoxClass" :style="
|
|
20
|
-
<div ui-hide=">mob" class="mob-follow-tips w-full
|
|
19
|
+
<div ref="box" :ui-tips-box="state.pos" :class="tipBoxClass" :style="tipBoxStyle">
|
|
20
|
+
<div ui-hide=">mob" class="mob-follow-tips w-full nx-sl nt-sl nb-ss" ui-flex="row xm">
|
|
21
21
|
<div class="w-xs*s"></div>
|
|
22
22
|
<div>
|
|
23
23
|
<h5 class="bold">{{ title }}</h5>
|
|
24
24
|
</div>
|
|
25
25
|
<div class="w-xs*s" ui-flex="row rm">
|
|
26
26
|
<button ui-btn="@a none s :square" @click="toggle">
|
|
27
|
-
<i
|
|
27
|
+
<i ui-icon="close"></i>
|
|
28
28
|
</button>
|
|
29
29
|
</div>
|
|
30
30
|
</div>
|
|
@@ -35,8 +35,15 @@
|
|
|
35
35
|
</Teleport>
|
|
36
36
|
</div>
|
|
37
37
|
</template>
|
|
38
|
+
<script lang="ts">
|
|
39
|
+
// 每个实例只登记自己的后代,Teleport 后仍可辨认嵌套浮层。
|
|
40
|
+
type FollowBoundary = { contains: (target: Node) => boolean; hide: () => void };
|
|
41
|
+
// hover:Teleport 内容的鼠标进出不会冒泡到祖先,需要逐级通知。
|
|
42
|
+
type FollowContext = { children: Set<FollowBoundary>; hover: (on: boolean) => void };
|
|
43
|
+
const followContextKey = Symbol('follow-context');
|
|
44
|
+
</script>
|
|
38
45
|
<script setup lang="ts">
|
|
39
|
-
import { reactive, ref, onMounted, onUnmounted, watch } from 'vue';
|
|
46
|
+
import { reactive, ref, onMounted, onUnmounted, watch, inject, provide } from 'vue';
|
|
40
47
|
import Follow from '@fekit/follow';
|
|
41
48
|
|
|
42
49
|
const emits = defineEmits(['update:modelValue', 'onhide', 'onshow']);
|
|
@@ -55,6 +62,7 @@
|
|
|
55
62
|
tipBoxStyle?: string;
|
|
56
63
|
arrow?: any;
|
|
57
64
|
clickTipHide?: boolean;
|
|
65
|
+
/** @deprecated 已移除,不再生效,将在 6.0 删除该属性。 */
|
|
58
66
|
dragTipHide?: boolean;
|
|
59
67
|
mobile?: boolean;
|
|
60
68
|
title?: string;
|
|
@@ -75,7 +83,7 @@
|
|
|
75
83
|
tipBoxStyle: '',
|
|
76
84
|
arrow: {},
|
|
77
85
|
clickTipHide: false,
|
|
78
|
-
dragTipHide:
|
|
86
|
+
dragTipHide: undefined,
|
|
79
87
|
mobile: true,
|
|
80
88
|
title: '',
|
|
81
89
|
zIndex: undefined,
|
|
@@ -87,87 +95,23 @@
|
|
|
87
95
|
const teleportReady = ref(false);
|
|
88
96
|
const state = reactive({
|
|
89
97
|
view: 0,
|
|
90
|
-
show: 0,
|
|
98
|
+
show: props.modelValue ? 1 : 0,
|
|
91
99
|
pos: props.pos,
|
|
92
100
|
hover: 0,
|
|
93
|
-
tipdom: false,
|
|
94
|
-
isMobile: false,
|
|
95
101
|
});
|
|
96
102
|
|
|
103
|
+
if (props.dragTipHide !== undefined) {
|
|
104
|
+
console.warn('「IFollowView」 dragTipHide 已移除且不再生效,将在 6.0 删除该属性,请移除 drag-tip-hide。');
|
|
105
|
+
}
|
|
106
|
+
|
|
97
107
|
const showTimer: any = ref(null);
|
|
98
108
|
const hideTimer: any = ref(null);
|
|
99
|
-
const dragPointerId = ref<number | null>(null);
|
|
100
|
-
const dragFromInternal = ref(false);
|
|
101
|
-
const dragStartX = ref(0);
|
|
102
|
-
const dragStartY = ref(0);
|
|
103
|
-
const dragPassing = ref(false);
|
|
104
|
-
const DRAG_HIDE_PX = 6;
|
|
105
|
-
|
|
106
109
|
const containsPoint = (el: HTMLElement | null, x: number, y: number) => {
|
|
107
110
|
if (!el) return false;
|
|
108
111
|
const rect = el.getBoundingClientRect();
|
|
109
112
|
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
|
110
113
|
};
|
|
111
114
|
|
|
112
|
-
const isOutsideFollow = (x: number, y: number) => {
|
|
113
|
-
return !containsPoint(box.value, x, y) && !containsPoint(dom.value, x, y);
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
const isInternalDragSource = (target: EventTarget | null) => {
|
|
117
|
-
if (!(target instanceof Element)) return false;
|
|
118
|
-
if (target.closest('input, textarea, select, [contenteditable="true"], [role="slider"]')) return true;
|
|
119
|
-
const stop = box.value?.parentElement || null;
|
|
120
|
-
let el: Element | null = target;
|
|
121
|
-
while (el && el !== stop) {
|
|
122
|
-
const style = window.getComputedStyle(el);
|
|
123
|
-
const canScroll =
|
|
124
|
-
((style.overflowY === 'auto' || style.overflowY === 'scroll') && el.scrollHeight > el.clientHeight) ||
|
|
125
|
-
((style.overflowX === 'auto' || style.overflowX === 'scroll') && el.scrollWidth > el.clientWidth);
|
|
126
|
-
if (canScroll) return true;
|
|
127
|
-
el = el.parentElement;
|
|
128
|
-
}
|
|
129
|
-
return false;
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const clearDragSession = () => {
|
|
133
|
-
dragPointerId.value = null;
|
|
134
|
-
dragFromInternal.value = false;
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const hideFromDrag = () => {
|
|
138
|
-
dragPassing.value = true;
|
|
139
|
-
state.show = 0;
|
|
140
|
-
state.hover = 0;
|
|
141
|
-
clearDragSession();
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const onDragPointerDown = (ev: PointerEvent) => {
|
|
145
|
-
if (!props.dragTipHide || !state.show || ev.button !== 0) return;
|
|
146
|
-
if (!box.value?.contains(ev.target as Node)) return;
|
|
147
|
-
dragPointerId.value = ev.pointerId;
|
|
148
|
-
dragFromInternal.value = isInternalDragSource(ev.target);
|
|
149
|
-
dragStartX.value = ev.clientX;
|
|
150
|
-
dragStartY.value = ev.clientY;
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
const onDragPointerMove = (ev: PointerEvent) => {
|
|
154
|
-
if (!props.dragTipHide || dragPointerId.value !== ev.pointerId || ev.buttons === 0) return;
|
|
155
|
-
if (isOutsideFollow(ev.clientX, ev.clientY)) {
|
|
156
|
-
hideFromDrag();
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
if (dragFromInternal.value) return;
|
|
160
|
-
const dx = ev.clientX - dragStartX.value;
|
|
161
|
-
const dy = ev.clientY - dragStartY.value;
|
|
162
|
-
if (dx * dx + dy * dy >= DRAG_HIDE_PX * DRAG_HIDE_PX) {
|
|
163
|
-
hideFromDrag();
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
const onDragPointerUp = (ev: PointerEvent) => {
|
|
168
|
-
if (dragPointerId.value === ev.pointerId) clearDragSession();
|
|
169
|
-
};
|
|
170
|
-
|
|
171
115
|
watch(
|
|
172
116
|
() => state.hover,
|
|
173
117
|
() => {
|
|
@@ -183,26 +127,86 @@
|
|
|
183
127
|
}, 100);
|
|
184
128
|
}
|
|
185
129
|
},
|
|
130
|
+
// 同步创建定时器,使同一事件中随后的 hide()/show() 能够取消它。
|
|
131
|
+
{ flush: 'sync' },
|
|
186
132
|
);
|
|
187
133
|
|
|
134
|
+
// 主动切换状态时取消待执行的 hover 定时器,避免被延迟回调覆盖。
|
|
135
|
+
const setShow = (value: number) => {
|
|
136
|
+
clearTimeout(showTimer.value);
|
|
137
|
+
clearTimeout(hideTimer.value);
|
|
138
|
+
state.show = value;
|
|
139
|
+
};
|
|
140
|
+
|
|
188
141
|
// 开关
|
|
189
142
|
const toggle = () => {
|
|
190
143
|
if (props.event === 'click') {
|
|
191
|
-
state.show
|
|
144
|
+
setShow(state.show ? 0 : 1);
|
|
192
145
|
}
|
|
193
146
|
};
|
|
194
147
|
|
|
148
|
+
// 外部 v-model 控制显示状态。
|
|
195
149
|
watch(
|
|
196
|
-
|
|
197
|
-
() => {
|
|
198
|
-
|
|
150
|
+
() => props.modelValue,
|
|
151
|
+
(value) => {
|
|
152
|
+
const next = value ? 1 : 0;
|
|
153
|
+
if (next !== state.show) setShow(next);
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// 只在实际显示状态变化时通知;父层关闭时同步关闭后代浮层(Teleport 后不会随父层隐藏)。
|
|
158
|
+
watch(
|
|
159
|
+
() => state.show,
|
|
160
|
+
(show) => {
|
|
161
|
+
if (show) {
|
|
199
162
|
emits('onshow');
|
|
200
163
|
} else {
|
|
164
|
+
children.forEach(item => item.hide());
|
|
201
165
|
emits('onhide');
|
|
202
166
|
}
|
|
203
|
-
emits('update:modelValue',
|
|
167
|
+
emits('update:modelValue', show);
|
|
204
168
|
},
|
|
205
|
-
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// 移动端底部面板放入浏览器 top layer(Popover API),无需比较 z-index 即可盖住整页;
|
|
172
|
+
// 桌面端不加 popover 属性,仍按 Follow 的祖先层级定位。不支持的浏览器退回普通 fixed。
|
|
173
|
+
const SHEET_LEAVE_MS = 500;
|
|
174
|
+
let sheetTimer: ReturnType<typeof setTimeout> | undefined;
|
|
175
|
+
const supportsPopover = () => typeof HTMLElement !== 'undefined' && 'showPopover' in HTMLElement.prototype;
|
|
176
|
+
const isSheet = () => props.mobile && window.matchMedia('(max-width: 799px)').matches;
|
|
177
|
+
const releaseSheet = () => {
|
|
178
|
+
clearTimeout(sheetTimer);
|
|
179
|
+
const el = tip.value;
|
|
180
|
+
if (!el?.hasAttribute('popover')) return;
|
|
181
|
+
try {
|
|
182
|
+
if (el.matches(':popover-open')) el.hidePopover();
|
|
183
|
+
} catch {}
|
|
184
|
+
el.removeAttribute('popover');
|
|
185
|
+
};
|
|
186
|
+
watch(
|
|
187
|
+
() => state.show,
|
|
188
|
+
(show) => {
|
|
189
|
+
const el = tip.value;
|
|
190
|
+
if (!el || !supportsPopover()) return;
|
|
191
|
+
clearTimeout(sheetTimer);
|
|
192
|
+
if (show) {
|
|
193
|
+
if (!isSheet()) return;
|
|
194
|
+
el.setAttribute('popover', 'manual');
|
|
195
|
+
try {
|
|
196
|
+
// 已打开时先关再开,使其移到 top layer 最上方。
|
|
197
|
+
if (el.matches(':popover-open')) el.hidePopover();
|
|
198
|
+
el.showPopover();
|
|
199
|
+
} catch {
|
|
200
|
+
el.removeAttribute('popover');
|
|
201
|
+
}
|
|
202
|
+
} else if (el.hasAttribute('popover')) {
|
|
203
|
+
// 等滑出动画结束再离开 top layer。
|
|
204
|
+
sheetTimer = setTimeout(() => {
|
|
205
|
+
if (!state.show) releaseSheet();
|
|
206
|
+
}, SHEET_LEAVE_MS);
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
{ flush: 'post' },
|
|
206
210
|
);
|
|
207
211
|
|
|
208
212
|
const hovershow = () => {
|
|
@@ -219,35 +223,112 @@
|
|
|
219
223
|
|
|
220
224
|
const clickhide = () => {
|
|
221
225
|
if (props.clickTipHide) {
|
|
222
|
-
|
|
226
|
+
setShow(0);
|
|
223
227
|
}
|
|
224
228
|
};
|
|
225
229
|
|
|
226
230
|
const show = () => {
|
|
227
|
-
|
|
228
|
-
state.show = 1;
|
|
231
|
+
setShow(1);
|
|
229
232
|
};
|
|
230
233
|
|
|
231
234
|
const hide = () => {
|
|
232
|
-
|
|
233
|
-
|
|
235
|
+
setShow(0);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const parentContext = inject<FollowContext | null>(followContextKey, null);
|
|
239
|
+
const parentChildren = parentContext?.children ?? null;
|
|
240
|
+
const children = new Set<FollowBoundary>();
|
|
241
|
+
// 后代浮层内容的进出视同本层;本层 tip 也是 Teleport 内容,继续向上通知。
|
|
242
|
+
const notifyHover = (on: boolean) => {
|
|
243
|
+
if (props.event === 'hover') state.hover = on ? 1 : 0;
|
|
244
|
+
parentContext?.hover(on);
|
|
245
|
+
};
|
|
246
|
+
const tipHover = (on: number) => notifyHover(!!on);
|
|
247
|
+
provide(followContextKey, { children, hover: notifyHover });
|
|
248
|
+
const inChildren = (target: Node): boolean => [...children].some(item => item.contains(target));
|
|
249
|
+
const containsFollow: FollowBoundary = {
|
|
250
|
+
contains: target => !!dom.value?.contains(target) || (!!state.show && (!!tip.value?.contains(target) || inChildren(target))),
|
|
251
|
+
hide: () => {
|
|
252
|
+
state.hover = 0;
|
|
253
|
+
setShow(0);
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
parentChildren?.add(containsFollow);
|
|
257
|
+
const isInside = (target: Node) => !!dom.value?.contains(target) || !!box.value?.contains(target) || inChildren(target);
|
|
258
|
+
const isBackdrop = (target: EventTarget | null) =>
|
|
259
|
+
props.mobile && window.matchMedia('(max-width: 799px)').matches && target === tip.value;
|
|
260
|
+
|
|
261
|
+
const dismissOutside = () => {
|
|
262
|
+
// 暗层 touchstart 被取消默认行为时,主动失焦,让表单先提交再关闭。
|
|
263
|
+
const active = document.activeElement;
|
|
264
|
+
if (active instanceof HTMLElement && isInside(active)) active.blur();
|
|
265
|
+
clearTimeout(showTimer.value);
|
|
266
|
+
clearTimeout(hideTimer.value);
|
|
267
|
+
state.hover = 0;
|
|
268
|
+
hide();
|
|
234
269
|
};
|
|
235
270
|
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
271
|
+
// 记录本次按下是否发生在浮层内:在内部按下、拖到外部松开(拖选文字、拖滑块等)
|
|
272
|
+
// 产生的 click 目标是共同祖先,不能当作外部点击。
|
|
273
|
+
let pressedInside = false;
|
|
274
|
+
const onPressStart = (event: PointerEvent) => {
|
|
275
|
+
pressedInside = !!state.show && event.target instanceof Node && isInside(event.target);
|
|
276
|
+
};
|
|
277
|
+
// 松开后紧随的 click 处理完再清除;没有 click(触摸滚动等)时也不残留到下一次键盘点击。
|
|
278
|
+
const onPressEnd = () => {
|
|
279
|
+
if (pressedInside) setTimeout(() => { pressedInside = false; }, 0);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// 外部点击以内容盒子为边界;按下位置在内部时不关闭。
|
|
283
|
+
const cancel = (event?: MouseEvent) => {
|
|
284
|
+
const fromInside = pressedInside;
|
|
285
|
+
pressedInside = false;
|
|
286
|
+
if (!state.show) return;
|
|
287
|
+
if (event && fromInside) return;
|
|
288
|
+
const target = event?.target;
|
|
289
|
+
if (target instanceof Node && isInside(target)) return;
|
|
290
|
+
if (event && isBackdrop(target ?? null)) {
|
|
291
|
+
event.preventDefault();
|
|
292
|
+
// 同一 document 上父浮层的监听器也不能继续处理子暗层点击。
|
|
293
|
+
event.stopImmediatePropagation();
|
|
240
294
|
}
|
|
295
|
+
dismissOutside();
|
|
241
296
|
};
|
|
242
297
|
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
298
|
+
// 轻触跟踪:暗层轻触由组件自己处理(阻止点穿);外部轻触在 touchend 关闭,
|
|
299
|
+
// 因为 iOS/WebKit 轻触非可点击元素不会派发 click。
|
|
300
|
+
const TAP_SLOP = 10;
|
|
301
|
+
let outsideTouch: { id: number; x: number; y: number; moved: boolean; backdrop: boolean } | null = null;
|
|
302
|
+
const onTouchStart = (event: TouchEvent) => {
|
|
303
|
+
outsideTouch = null;
|
|
304
|
+
if (!state.show) return;
|
|
305
|
+
const target = event.target;
|
|
306
|
+
const backdrop = isBackdrop(target);
|
|
307
|
+
if (!backdrop && (!(target instanceof Node) || isInside(target))) return;
|
|
308
|
+
if (backdrop) event.preventDefault();
|
|
309
|
+
const touch = event.touches[0];
|
|
310
|
+
if (event.touches.length === 1 && touch) {
|
|
311
|
+
outsideTouch = { id: touch.identifier, x: touch.clientX, y: touch.clientY, moved: false, backdrop };
|
|
249
312
|
}
|
|
250
313
|
};
|
|
314
|
+
const onTouchMove = (event: TouchEvent) => {
|
|
315
|
+
if (!outsideTouch) return;
|
|
316
|
+
// 暗层上阻止页面滚动;外部区域保持原生滚动,滚动则不视为轻触。
|
|
317
|
+
if (outsideTouch.backdrop) event.preventDefault();
|
|
318
|
+
const start = outsideTouch;
|
|
319
|
+
const touch = [...event.touches].find(item => item.identifier === start.id);
|
|
320
|
+
if (!touch || event.touches.length !== 1 || Math.hypot(touch.clientX - start.x, touch.clientY - start.y) > TAP_SLOP) start.moved = true;
|
|
321
|
+
};
|
|
322
|
+
const onTouchEnd = (event: TouchEvent) => {
|
|
323
|
+
const start = outsideTouch;
|
|
324
|
+
outsideTouch = null;
|
|
325
|
+
if (!start) return;
|
|
326
|
+
if (start.backdrop) event.preventDefault();
|
|
327
|
+
const touch = [...event.changedTouches].find(item => item.identifier === start.id);
|
|
328
|
+
if (!state.show || start.moved || !touch || Math.hypot(touch.clientX - start.x, touch.clientY - start.y) > TAP_SLOP) return;
|
|
329
|
+
if (start.backdrop ? isBackdrop(event.target) && !containsPoint(box.value, touch.clientX, touch.clientY) : true) dismissOutside();
|
|
330
|
+
};
|
|
331
|
+
const onTouchCancel = () => { outsideTouch = null; };
|
|
251
332
|
|
|
252
333
|
const ensureTeleportTarget = () => {
|
|
253
334
|
let area = document.querySelector('#ifollow');
|
|
@@ -261,24 +342,21 @@
|
|
|
261
342
|
|
|
262
343
|
onMounted(() => {
|
|
263
344
|
ensureTeleportTarget();
|
|
345
|
+
document.addEventListener('pointerdown', onPressStart, true);
|
|
346
|
+
document.addEventListener('pointerup', onPressEnd, true);
|
|
347
|
+
document.addEventListener('pointercancel', onPressEnd, true);
|
|
264
348
|
document.addEventListener('click', cancel, true);
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
document.addEventListener('touchstart', preventTouchScroll, {
|
|
271
|
-
passive: false,
|
|
272
|
-
});
|
|
273
|
-
document.addEventListener('touchmove', preventTouchScroll, {
|
|
274
|
-
passive: false,
|
|
275
|
-
});
|
|
276
|
-
// 判断是否为移动端
|
|
277
|
-
state.isMobile = window.innerWidth < 800;
|
|
349
|
+
// 暗层需要阻止默认行为,触摸监听不能是 passive。
|
|
350
|
+
document.addEventListener('touchstart', onTouchStart, { passive: false });
|
|
351
|
+
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
352
|
+
document.addEventListener('touchend', onTouchEnd, { passive: false });
|
|
353
|
+
document.addEventListener('touchcancel', onTouchCancel);
|
|
278
354
|
});
|
|
279
355
|
|
|
280
356
|
const ex: any = ref(null);
|
|
281
357
|
watch([() => dom.value, () => tip.value], () => {
|
|
358
|
+
// 替换或移除 tips 前先注销旧实例的监听。
|
|
359
|
+
ex.value?.remove();
|
|
282
360
|
if (dom.value && tip.value) {
|
|
283
361
|
ex.value = new Follow(
|
|
284
362
|
dom.value,
|
|
@@ -300,6 +378,8 @@
|
|
|
300
378
|
},
|
|
301
379
|
{ hideOnScrollOut: false, zIndex: props.zIndex },
|
|
302
380
|
);
|
|
381
|
+
// 初始即显示(如 modelValue=1)时 state.show 不会再变化,需要立即启动定位。
|
|
382
|
+
if (state.show) ex.value.listen();
|
|
303
383
|
} else {
|
|
304
384
|
ex.value = null;
|
|
305
385
|
}
|
|
@@ -313,14 +393,17 @@
|
|
|
313
393
|
);
|
|
314
394
|
|
|
315
395
|
onUnmounted(() => {
|
|
396
|
+
document.removeEventListener('pointerdown', onPressStart, true);
|
|
397
|
+
document.removeEventListener('pointerup', onPressEnd, true);
|
|
398
|
+
document.removeEventListener('pointercancel', onPressEnd, true);
|
|
316
399
|
document.removeEventListener('click', cancel, true);
|
|
317
|
-
document.removeEventListener('
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
400
|
+
document.removeEventListener('touchstart', onTouchStart);
|
|
401
|
+
document.removeEventListener('touchmove', onTouchMove);
|
|
402
|
+
document.removeEventListener('touchend', onTouchEnd);
|
|
403
|
+
document.removeEventListener('touchcancel', onTouchCancel);
|
|
404
|
+
parentChildren?.delete(containsFollow);
|
|
405
|
+
releaseSheet();
|
|
406
|
+
outsideTouch = null;
|
|
324
407
|
state.show = 0;
|
|
325
408
|
|
|
326
409
|
if (ex.value) {
|
|
@@ -331,11 +414,6 @@
|
|
|
331
414
|
watch(
|
|
332
415
|
() => state.show,
|
|
333
416
|
(show: any) => {
|
|
334
|
-
if (show) {
|
|
335
|
-
dragPassing.value = false;
|
|
336
|
-
} else {
|
|
337
|
-
clearDragSession();
|
|
338
|
-
}
|
|
339
417
|
if (ex.value) {
|
|
340
418
|
if (show) {
|
|
341
419
|
ex.value.listen();
|
|
@@ -355,6 +433,17 @@
|
|
|
355
433
|
</script>
|
|
356
434
|
<style lang="scss">
|
|
357
435
|
@use '@neatui/css/query' as neatui;
|
|
436
|
+
// 抵消 [popover] 的 UA 默认样式;放在媒体查询外,面板打开时切到桌面宽度也不会走样。
|
|
437
|
+
.fekit-follow-mobile[popover] {
|
|
438
|
+
margin: 0;
|
|
439
|
+
padding: 0;
|
|
440
|
+
border: 0;
|
|
441
|
+
overflow: visible;
|
|
442
|
+
color: inherit;
|
|
443
|
+
background: transparent;
|
|
444
|
+
max-width: none;
|
|
445
|
+
max-height: none;
|
|
446
|
+
}
|
|
358
447
|
@include neatui.mob {
|
|
359
448
|
.fekit-follow-mobile {
|
|
360
449
|
position: fixed !important;
|
|
@@ -368,27 +457,52 @@
|
|
|
368
457
|
align-content: center !important;
|
|
369
458
|
inset: 0 0 0 0 !important;
|
|
370
459
|
|
|
460
|
+
// 底部面板:暗层淡入淡出,面板从底部滑入滑出(iOS 风格减速曲线)。
|
|
461
|
+
--follow-sheet-ease: cubic-bezier(0.32, 0.72, 0, 1);
|
|
462
|
+
background-color: rgba(0, 0, 0, 0);
|
|
463
|
+
transition: background-color 0.4s var(--follow-sheet-ease);
|
|
464
|
+
|
|
371
465
|
&[ui-tips-view='1'] {
|
|
372
466
|
background-color: rgba(0, 0, 0, 0.5);
|
|
373
467
|
pointer-events: auto !important;
|
|
374
|
-
touch-action: none !important;
|
|
375
468
|
}
|
|
376
469
|
|
|
377
|
-
&[ui-tips] [ui-tips-box] {
|
|
470
|
+
&[ui-tips][ui-tips-view='1'] > [ui-tips-box] {
|
|
471
|
+
transform: translate3d(0, 0, 0) !important;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
&[ui-tips] > [ui-tips-box] {
|
|
378
475
|
position: relative !important;
|
|
379
476
|
|
|
380
477
|
width: 100% !important;
|
|
381
|
-
bottom:
|
|
478
|
+
bottom: 0 !important;
|
|
479
|
+
// iPhone Home 指示条安全区:面板背景铺到屏幕底边,内容让出安全区。
|
|
480
|
+
// 用透明下边框而非 padding,避免覆盖各组件 tipBoxClass 的内边距(背景默认绘制到边框下)。
|
|
481
|
+
border-bottom: env(safe-area-inset-bottom, 0px) solid transparent !important;
|
|
382
482
|
right: unset !important;
|
|
383
483
|
top: unset !important;
|
|
384
484
|
left: unset !important;
|
|
385
|
-
|
|
485
|
+
border-radius: 0.75em 0.75em 0 0 !important;
|
|
486
|
+
// 上方间距统一由标题栏提供,忽略各组件 tipBoxClass 的上内边距(如 ny-sm)。
|
|
487
|
+
padding-top: 0 !important;
|
|
488
|
+
// 全程不透明,只做位移。
|
|
489
|
+
opacity: 1 !important;
|
|
490
|
+
transform: translate3d(0, 100%, 0) !important;
|
|
491
|
+
transition: transform 0.45s var(--follow-sheet-ease) !important;
|
|
492
|
+
will-change: transform;
|
|
386
493
|
|
|
387
494
|
display: flex !important;
|
|
388
495
|
flex-direction: column !important;
|
|
389
496
|
justify-content: flex-end !important;
|
|
390
|
-
align-items:
|
|
391
|
-
align-content:
|
|
497
|
+
align-items: stretch !important;
|
|
498
|
+
align-content: stretch !important;
|
|
499
|
+
|
|
500
|
+
// 插槽内容撑满面板宽度,覆盖桌面端写死的宽度(如 w-ml*s)。
|
|
501
|
+
> * {
|
|
502
|
+
width: 100% !important;
|
|
503
|
+
max-width: 100%;
|
|
504
|
+
box-sizing: border-box;
|
|
505
|
+
}
|
|
392
506
|
|
|
393
507
|
[ui-tips-arrow] {
|
|
394
508
|
display: none;
|
|
@@ -396,9 +510,15 @@
|
|
|
396
510
|
}
|
|
397
511
|
}
|
|
398
512
|
|
|
513
|
+
@media (prefers-reduced-motion: reduce) {
|
|
514
|
+
.fekit-follow-mobile,
|
|
515
|
+
.fekit-follow-mobile[ui-tips] > [ui-tips-box] {
|
|
516
|
+
transition-duration: 0.01s !important;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
399
520
|
.mob-follow-tips {
|
|
400
521
|
position: relative;
|
|
401
|
-
padding-bottom: env(safe-area-inset-bottom, 16px);
|
|
402
522
|
&::before {
|
|
403
523
|
position: absolute;
|
|
404
524
|
content: '';
|
|
@@ -407,7 +527,7 @@
|
|
|
407
527
|
height: 3px;
|
|
408
528
|
border-radius: 1em;
|
|
409
529
|
background-color: var(--co-text-o-ss);
|
|
410
|
-
top:
|
|
530
|
+
top: 6px;
|
|
411
531
|
left: 50%;
|
|
412
532
|
transform: translateX(-50%);
|
|
413
533
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
:view="state.view"
|
|
13
13
|
class="fekit-layer"
|
|
14
14
|
>
|
|
15
|
-
<div :ui-flex="`col ${flex}`" :style="wrapStyle" class="fekit-layer-wrap" @animationend="anim" @click="_sys_hide">
|
|
15
|
+
<div ref="wrapRef" :ui-flex="`col ${flex}`" :style="wrapStyle" class="fekit-layer-wrap" @animationend="anim" @pointerdown="_sys_press" @pointerup="_sys_release" @click="_sys_hide">
|
|
16
16
|
<div v-if="state.coverDark !== ''" class="fekit-layer-cover-dark"></div>
|
|
17
17
|
<slot :param="_tasks[id]?.params"></slot>
|
|
18
18
|
</div>
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
</div>
|
|
22
22
|
</template>
|
|
23
23
|
<script lang="ts" setup>
|
|
24
|
-
import { computed, nextTick, onBeforeUnmount, reactive, watch } from 'vue';
|
|
24
|
+
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
|
25
25
|
import { env, isFunction } from '@fekit/utils';
|
|
26
26
|
|
|
27
27
|
// 弹层队列
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
el: HTMLElement | null;
|
|
74
74
|
listening: boolean;
|
|
75
75
|
owners: Set<string>;
|
|
76
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
76
77
|
view: number;
|
|
77
78
|
};
|
|
78
79
|
queue: Array<{ id: string; kind: 'open' | 'show'; params: any; then: any }>;
|
|
@@ -186,9 +187,65 @@
|
|
|
186
187
|
return task.view === 1 && !!task.parkAfterHide;
|
|
187
188
|
};
|
|
188
189
|
|
|
190
|
+
const parseTimeList = (value: string) =>
|
|
191
|
+
value.split(',').map((item) => {
|
|
192
|
+
const text = item.trim();
|
|
193
|
+
const num = Number.parseFloat(text) || 0;
|
|
194
|
+
return text.endsWith('ms') ? num : num * 1000;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// 读取元素当前动画的最长耗时(ms);没有动画返回 0
|
|
198
|
+
const getAnimationWait = (el: Element | null | undefined) => {
|
|
199
|
+
if (!el || !env.client) return 0;
|
|
200
|
+
const style = window.getComputedStyle(el);
|
|
201
|
+
const names = style.animationName.split(',');
|
|
202
|
+
const durations = parseTimeList(style.animationDuration);
|
|
203
|
+
const delays = parseTimeList(style.animationDelay);
|
|
204
|
+
let wait = 0;
|
|
205
|
+
names.forEach((name, index) => {
|
|
206
|
+
if (name.trim() === 'none') return;
|
|
207
|
+
const duration = durations[index % durations.length] || 0;
|
|
208
|
+
const delay = delays[index % delays.length] || 0;
|
|
209
|
+
wait = Math.max(wait, duration + delay);
|
|
210
|
+
});
|
|
211
|
+
return wait;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// animationend 兜底:动画缺失/被禁用/事件丢失时也能推进状态
|
|
215
|
+
const ANIMATION_FALLBACK_SLACK = 100;
|
|
216
|
+
|
|
217
|
+
const onGlobalDarkAnimationDone = (group: AreaGroup) => {
|
|
218
|
+
if (group.dark.timer) {
|
|
219
|
+
clearTimeout(group.dark.timer);
|
|
220
|
+
group.dark.timer = undefined;
|
|
221
|
+
}
|
|
222
|
+
if (group.dark.view === 2) setGlobalDarkView(group, 3);
|
|
223
|
+
else if (group.dark.view === 1) {
|
|
224
|
+
setGlobalDarkView(group, 0);
|
|
225
|
+
const afterHide = group.dark.afterHide;
|
|
226
|
+
group.dark.afterHide = undefined;
|
|
227
|
+
afterHide?.();
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
189
231
|
const setGlobalDarkView = (group: AreaGroup, view: number) => {
|
|
190
232
|
group.dark.view = view;
|
|
191
|
-
if (group.dark.
|
|
233
|
+
if (group.dark.timer) {
|
|
234
|
+
clearTimeout(group.dark.timer);
|
|
235
|
+
group.dark.timer = undefined;
|
|
236
|
+
}
|
|
237
|
+
const el = group.dark.el;
|
|
238
|
+
if (!el) return;
|
|
239
|
+
el.setAttribute('view', `${view}`);
|
|
240
|
+
if (view !== 1 && view !== 2) return;
|
|
241
|
+
const wait = getAnimationWait(el);
|
|
242
|
+
group.dark.timer = setTimeout(
|
|
243
|
+
() => {
|
|
244
|
+
group.dark.timer = undefined;
|
|
245
|
+
if (group.dark.view === view) onGlobalDarkAnimationDone(group);
|
|
246
|
+
},
|
|
247
|
+
wait > 0 ? wait + ANIMATION_FALLBACK_SLACK : 0,
|
|
248
|
+
);
|
|
192
249
|
};
|
|
193
250
|
|
|
194
251
|
const ensureGlobalDarkEl = (area: string, group: AreaGroup) => {
|
|
@@ -208,13 +265,7 @@
|
|
|
208
265
|
group.dark.el = el;
|
|
209
266
|
el.addEventListener('animationend', (ev) => {
|
|
210
267
|
if (ev.target !== el) return;
|
|
211
|
-
|
|
212
|
-
if (group.dark.view === 1) {
|
|
213
|
-
setGlobalDarkView(group, 0);
|
|
214
|
-
const afterHide = group.dark.afterHide;
|
|
215
|
-
group.dark.afterHide = undefined;
|
|
216
|
-
afterHide?.();
|
|
217
|
-
}
|
|
268
|
+
onGlobalDarkAnimationDone(group);
|
|
218
269
|
});
|
|
219
270
|
group.dark.listening = true;
|
|
220
271
|
return el;
|
|
@@ -316,8 +367,17 @@
|
|
|
316
367
|
syncStackIndex(group);
|
|
317
368
|
};
|
|
318
369
|
|
|
370
|
+
const runQueuedCancel = (job: { params: any }) => {
|
|
371
|
+
const cancel = job.params?.on?.cancel;
|
|
372
|
+
if (typeof cancel === 'function') cancel();
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// 撤掉队列中的显示请求:该请求不会再显示,视为取消
|
|
319
376
|
const removeFromQueue = (group: AreaGroup, layerId: string) => {
|
|
377
|
+
const removed = group.queue.filter((job) => job.id === layerId);
|
|
378
|
+
if (!removed.length) return;
|
|
320
379
|
group.queue = group.queue.filter((job) => job.id !== layerId);
|
|
380
|
+
removed.forEach(runQueuedCancel);
|
|
321
381
|
};
|
|
322
382
|
|
|
323
383
|
const getVisibleTop = (group: AreaGroup, exceptId = '') => {
|
|
@@ -506,13 +566,23 @@
|
|
|
506
566
|
wrapStyle: '',
|
|
507
567
|
});
|
|
508
568
|
|
|
509
|
-
|
|
510
|
-
|
|
569
|
+
// 容器不存在时:仅 #id 形式可自动创建;其它选择器无法据此建出可匹配的节点,只提示
|
|
570
|
+
if (env.client && props.area) {
|
|
571
|
+
let _area: Element | null = null;
|
|
572
|
+
try {
|
|
573
|
+
_area = document.querySelector(props.area);
|
|
574
|
+
} catch {
|
|
575
|
+
_area = null;
|
|
576
|
+
}
|
|
511
577
|
if (!_area) {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
578
|
+
if (/^#[\w-]+$/.test(props.area)) {
|
|
579
|
+
const el = document.createElement('div');
|
|
580
|
+
el.id = props.area.substring(1);
|
|
581
|
+
el.style.position = 'absolute';
|
|
582
|
+
document.body.appendChild(el);
|
|
583
|
+
} else {
|
|
584
|
+
console.warn('Layer 的 area 容器不存在,且只有 #id 形式可自动创建:', props.area);
|
|
585
|
+
}
|
|
516
586
|
}
|
|
517
587
|
}
|
|
518
588
|
|
|
@@ -528,6 +598,18 @@
|
|
|
528
598
|
view: 0,
|
|
529
599
|
});
|
|
530
600
|
const scrollLockOwner = {};
|
|
601
|
+
const wrapRef = ref<HTMLElement | null>(null);
|
|
602
|
+
// 本实例注册到 _tasks 的对象;同 id 被其它实例覆盖后不再写全局
|
|
603
|
+
let self: any = null;
|
|
604
|
+
const ownsRegistration = () => !!self && _tasks[props.id] === self;
|
|
605
|
+
|
|
606
|
+
let animTimer: ReturnType<typeof setTimeout> | undefined;
|
|
607
|
+
const clearAnimTimer = () => {
|
|
608
|
+
if (animTimer) {
|
|
609
|
+
clearTimeout(animTimer);
|
|
610
|
+
animTimer = undefined;
|
|
611
|
+
}
|
|
612
|
+
};
|
|
531
613
|
|
|
532
614
|
const layerStyle = computed(() => {
|
|
533
615
|
let style = '';
|
|
@@ -539,12 +621,12 @@
|
|
|
539
621
|
});
|
|
540
622
|
|
|
541
623
|
const syncTaskView = (view: number) => {
|
|
542
|
-
if (!
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
624
|
+
if (!self) return;
|
|
625
|
+
self.view = view;
|
|
626
|
+
self.openMode = state.openMode;
|
|
627
|
+
self.parkAfterHide = state.parkAfterHide;
|
|
628
|
+
self.participates = participatesInGroup(props.dark);
|
|
629
|
+
self.dark = props.dark;
|
|
548
630
|
};
|
|
549
631
|
|
|
550
632
|
const syncOwnDarkOccupancy = () => {
|
|
@@ -575,6 +657,7 @@
|
|
|
575
657
|
else releaseScrollLock(scrollLockOwner);
|
|
576
658
|
syncOwnDarkOccupancy();
|
|
577
659
|
state.immediate = false;
|
|
660
|
+
armAnimFallback();
|
|
578
661
|
if (participatesInGroup(props.dark) && props.id) {
|
|
579
662
|
nextTick(() => recomputeRecess(getAreaGroup(props.area)));
|
|
580
663
|
}
|
|
@@ -584,8 +667,30 @@
|
|
|
584
667
|
|
|
585
668
|
watch(
|
|
586
669
|
() => props.dark,
|
|
587
|
-
() => {
|
|
670
|
+
(dark, prevDark) => {
|
|
671
|
+
const was = participatesInGroup(prevDark);
|
|
672
|
+
const now = participatesInGroup(dark);
|
|
588
673
|
syncTaskView(state.view);
|
|
674
|
+
if (was !== now && env.client && props.id && ownsRegistration()) {
|
|
675
|
+
const group = getAreaGroup(props.area);
|
|
676
|
+
if (!now) {
|
|
677
|
+
// 退出分组:释放暗层占用并移出栈/队列;不恢复其下被 park 的弹层(不视为关闭)
|
|
678
|
+
removeFromQueue(group, props.id);
|
|
679
|
+
removeFromStack(group, props.id);
|
|
680
|
+
releaseGlobalDark(props.area, group, props.id);
|
|
681
|
+
state.coverDark = '';
|
|
682
|
+
state.recess = 0;
|
|
683
|
+
state.stackIndex = 0;
|
|
684
|
+
recomputeRecess(group);
|
|
685
|
+
scheduleFlushQueue(group);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// 加入分组:已打开时补上栈位置,暗层占用由 syncOwnDarkOccupancy 接管
|
|
689
|
+
if (state.view > 0) {
|
|
690
|
+
ensureOnStack(group, props.id);
|
|
691
|
+
recomputeRecess(group);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
589
694
|
syncOwnDarkOccupancy();
|
|
590
695
|
},
|
|
591
696
|
);
|
|
@@ -593,7 +698,7 @@
|
|
|
593
698
|
watch(
|
|
594
699
|
() => props.escClose,
|
|
595
700
|
(escClose) => {
|
|
596
|
-
if (
|
|
701
|
+
if (self) self.escClose = escClose;
|
|
597
702
|
},
|
|
598
703
|
{ immediate: true },
|
|
599
704
|
);
|
|
@@ -601,7 +706,7 @@
|
|
|
601
706
|
watch(
|
|
602
707
|
() => props.darkClickClose,
|
|
603
708
|
(darkClickClose) => {
|
|
604
|
-
if (
|
|
709
|
+
if (self) self.darkClickClose = darkClickClose;
|
|
605
710
|
},
|
|
606
711
|
{ immediate: true },
|
|
607
712
|
);
|
|
@@ -612,7 +717,10 @@
|
|
|
612
717
|
hide: [],
|
|
613
718
|
open: [],
|
|
614
719
|
none: [],
|
|
720
|
+
cancel: [],
|
|
615
721
|
};
|
|
722
|
+
// 已发起显示但尚未完成(show 回调未执行)
|
|
723
|
+
let showPending = false;
|
|
616
724
|
|
|
617
725
|
const resolveMode = (params: any): LayerMode => {
|
|
618
726
|
const mode = params?.mode;
|
|
@@ -623,14 +731,28 @@
|
|
|
623
731
|
const bindOpenCallbacks = (params: any = {}, then = () => {}) => {
|
|
624
732
|
if (params?.on?.show) on.show.push(params.on.show);
|
|
625
733
|
if (params?.on?.hide) on.hide.push(params.on.hide);
|
|
734
|
+
if (params?.on?.cancel) on.cancel.push(params.on.cancel);
|
|
626
735
|
on.show.push(then);
|
|
627
736
|
};
|
|
628
737
|
|
|
629
|
-
|
|
630
|
-
|
|
738
|
+
// 回调执行前,LayerById(id).view 与分组栈都已更新为最终状态
|
|
739
|
+
const runCallbacks = (key: 'show' | 'hide' | 'none' | 'cancel') => {
|
|
740
|
+
const list = on[key].splice(0);
|
|
741
|
+
if (key === 'show') {
|
|
742
|
+
showPending = false;
|
|
743
|
+
on.cancel.length = 0;
|
|
744
|
+
}
|
|
745
|
+
list.forEach((fun: any) => {
|
|
631
746
|
if (isFunction(fun)) fun();
|
|
632
747
|
});
|
|
633
|
-
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
// 显示尚未完成就被关闭:丢弃 show 回调,改为触发 cancel
|
|
751
|
+
const cancelPendingShow = () => {
|
|
752
|
+
if (!showPending) return;
|
|
753
|
+
showPending = false;
|
|
754
|
+
on.show.length = 0;
|
|
755
|
+
runCallbacks('cancel');
|
|
634
756
|
};
|
|
635
757
|
|
|
636
758
|
const flushQueue = (group: AreaGroup) => {
|
|
@@ -639,6 +761,7 @@
|
|
|
639
761
|
if (!job) return;
|
|
640
762
|
const task = _tasks[job.id];
|
|
641
763
|
if (!task) {
|
|
764
|
+
runQueuedCancel(job);
|
|
642
765
|
flushQueue(group);
|
|
643
766
|
return;
|
|
644
767
|
}
|
|
@@ -666,7 +789,9 @@
|
|
|
666
789
|
const group = getAreaGroup(props.area);
|
|
667
790
|
removeFromStack(group, layerId);
|
|
668
791
|
const topId = group.stack[group.stack.length - 1];
|
|
669
|
-
|
|
792
|
+
// 恢复已停放或正在退往停放的下层(unpark 自行判断)
|
|
793
|
+
const top = topId ? _tasks[topId] : undefined;
|
|
794
|
+
if (top && (top.view === 4 || top.parkAfterHide)) top.unpark();
|
|
670
795
|
recomputeRecess(group);
|
|
671
796
|
scheduleFlushQueue(group);
|
|
672
797
|
};
|
|
@@ -726,16 +851,24 @@
|
|
|
726
851
|
state.parkAfterHide = false;
|
|
727
852
|
state.immediate = true;
|
|
728
853
|
state.view = 4;
|
|
854
|
+
syncTaskView(4);
|
|
729
855
|
return;
|
|
730
856
|
}
|
|
731
857
|
if (state.view === 2 || state.view === 3) {
|
|
732
858
|
state.parkAfterHide = true;
|
|
733
|
-
syncTaskView(state.view);
|
|
734
859
|
state.view = 1;
|
|
860
|
+
syncTaskView(1);
|
|
735
861
|
}
|
|
736
862
|
};
|
|
737
863
|
|
|
738
864
|
const unpark = (options: any = {}) => {
|
|
865
|
+
// 退场动画未结束(尚未进入停放):取消停放,重新显示
|
|
866
|
+
if (state.view === 1 && state.parkAfterHide) {
|
|
867
|
+
state.parkAfterHide = false;
|
|
868
|
+
state.view = 2;
|
|
869
|
+
syncTaskView(2);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
739
872
|
if (state.view !== 4) return;
|
|
740
873
|
state.parkAfterHide = false;
|
|
741
874
|
if (options.immediate) {
|
|
@@ -746,16 +879,38 @@
|
|
|
746
879
|
state.view = 2;
|
|
747
880
|
};
|
|
748
881
|
|
|
882
|
+
// animationend 兜底:am 无对应样式、动画被禁用或事件丢失时,按动画时长推进 1→0 / 2→3
|
|
883
|
+
function armAnimFallback() {
|
|
884
|
+
clearAnimTimer();
|
|
885
|
+
const view = state.view;
|
|
886
|
+
if (!env.client || (view !== 1 && view !== 2)) return;
|
|
887
|
+
nextTick(() => {
|
|
888
|
+
if (state.view !== view) return;
|
|
889
|
+
clearAnimTimer();
|
|
890
|
+
const wait = getAnimationWait(wrapRef.value);
|
|
891
|
+
animTimer = setTimeout(
|
|
892
|
+
() => {
|
|
893
|
+
animTimer = undefined;
|
|
894
|
+
if (state.view === view) anim();
|
|
895
|
+
},
|
|
896
|
+
wait > 0 ? wait + ANIMATION_FALLBACK_SLACK : 0,
|
|
897
|
+
);
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
|
|
749
901
|
// 注入参数
|
|
750
902
|
const init = (params: any = {}) => {
|
|
751
|
-
|
|
903
|
+
self.params = params;
|
|
752
904
|
if (params?.on?.show) {
|
|
753
905
|
on.show.push(params.on.show);
|
|
754
906
|
}
|
|
755
907
|
if (params?.on?.hide) {
|
|
756
908
|
on.hide.push(params.on.hide);
|
|
757
909
|
}
|
|
758
|
-
|
|
910
|
+
if (params?.on?.cancel) {
|
|
911
|
+
on.cancel.push(params.on.cancel);
|
|
912
|
+
}
|
|
913
|
+
return self;
|
|
759
914
|
};
|
|
760
915
|
|
|
761
916
|
// 显示弹层
|
|
@@ -763,23 +918,35 @@
|
|
|
763
918
|
if (tryEnqueue('show', params, then)) return;
|
|
764
919
|
if (participatesInGroup(props.dark) && props.id) removeFromQueue(getAreaGroup(props.area), props.id);
|
|
765
920
|
bindOpenCallbacks(params, then);
|
|
921
|
+
showPending = true;
|
|
766
922
|
state.openMode = resolveMode(params);
|
|
767
923
|
applyIncomingRelation(state.openMode, params, false);
|
|
768
924
|
state.parkAfterHide = false;
|
|
769
925
|
state.view = 2;
|
|
926
|
+
// 立即同步全局状态,同一调用栈内随后的 queue 请求才能看到本层在显示
|
|
927
|
+
syncTaskView(2);
|
|
770
928
|
closeReplacedLayers(state.openMode, params, false);
|
|
771
929
|
syncGroupPresentation();
|
|
772
930
|
};
|
|
773
931
|
|
|
774
932
|
// 隐藏弹层
|
|
775
933
|
const hide = (_params: any = {}, then = () => {}) => {
|
|
934
|
+
// 先撤销排队中的显示请求(触发其 cancel),无论当前是否在显示
|
|
935
|
+
if (participatesInGroup(props.dark) && props.id) removeFromQueue(getAreaGroup(props.area), props.id);
|
|
936
|
+
// 已关闭:目标状态已达成,直接回调
|
|
937
|
+
if (state.view === 0) {
|
|
938
|
+
if (isFunction(then)) then();
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
cancelPendingShow();
|
|
776
942
|
if (state.view === 4) {
|
|
777
943
|
on.hide.push(then);
|
|
778
944
|
state.parkAfterHide = false;
|
|
779
945
|
state.immediate = true;
|
|
780
946
|
state.view = 0;
|
|
781
|
-
|
|
947
|
+
syncTaskView(0);
|
|
782
948
|
onGroupLayerClosed(id);
|
|
949
|
+
runCallbacks('hide');
|
|
783
950
|
return;
|
|
784
951
|
}
|
|
785
952
|
if (state.view === 2 || state.view === 3) {
|
|
@@ -787,7 +954,12 @@
|
|
|
787
954
|
syncTaskView(state.view);
|
|
788
955
|
state.view = 1;
|
|
789
956
|
on.hide.push(then);
|
|
957
|
+
return;
|
|
790
958
|
}
|
|
959
|
+
// 正在关闭:回调并入本次关闭;若是 park 的退场过渡,改为真正关闭
|
|
960
|
+
state.parkAfterHide = false;
|
|
961
|
+
syncTaskView(state.view);
|
|
962
|
+
on.hide.push(then);
|
|
791
963
|
};
|
|
792
964
|
|
|
793
965
|
// 打开弹层(没有动画过程)
|
|
@@ -800,6 +972,7 @@
|
|
|
800
972
|
state.parkAfterHide = false;
|
|
801
973
|
state.immediate = true;
|
|
802
974
|
state.view = 3;
|
|
975
|
+
syncTaskView(3);
|
|
803
976
|
closeReplacedLayers(state.openMode, params, true);
|
|
804
977
|
syncGroupPresentation();
|
|
805
978
|
runCallbacks('show');
|
|
@@ -808,18 +981,34 @@
|
|
|
808
981
|
const none = (_params: any = {}, then = () => {}) => {
|
|
809
982
|
const wasOpen = state.view > 0;
|
|
810
983
|
if (participatesInGroup(props.dark) && props.id) removeFromQueue(getAreaGroup(props.area), props.id);
|
|
984
|
+
cancelPendingShow();
|
|
811
985
|
state.parkAfterHide = false;
|
|
812
986
|
state.immediate = true;
|
|
813
987
|
state.view = 0;
|
|
988
|
+
syncTaskView(0);
|
|
814
989
|
on.none.push(then);
|
|
990
|
+
if (wasOpen) onGroupLayerClosed(id);
|
|
815
991
|
// 立即执行回调,因为没有动画
|
|
816
992
|
runCallbacks('none');
|
|
817
|
-
if (wasOpen) onGroupLayerClosed(id);
|
|
818
993
|
};
|
|
819
994
|
|
|
820
|
-
//
|
|
821
|
-
|
|
822
|
-
|
|
995
|
+
// 点暗层关闭:按下与松开都必须落在暗层(wrap 本身)上。
|
|
996
|
+
// 按下和松开分处弹窗内外时(如拖选输入框文本拖出弹窗),click 会派发到共同祖先 wrap,不能据此关闭。
|
|
997
|
+
let pressedOnBackdrop = false;
|
|
998
|
+
let releasedOnBackdrop = false;
|
|
999
|
+
const _sys_press = (ev: PointerEvent) => {
|
|
1000
|
+
pressedOnBackdrop = ev.target === ev.currentTarget;
|
|
1001
|
+
releasedOnBackdrop = false;
|
|
1002
|
+
};
|
|
1003
|
+
const _sys_release = (ev: PointerEvent) => {
|
|
1004
|
+
releasedOnBackdrop = ev.target === ev.currentTarget;
|
|
1005
|
+
};
|
|
1006
|
+
const _sys_hide = (ev: MouseEvent) => {
|
|
1007
|
+
const fromBackdrop = pressedOnBackdrop && releasedOnBackdrop;
|
|
1008
|
+
pressedOnBackdrop = false;
|
|
1009
|
+
releasedOnBackdrop = false;
|
|
1010
|
+
if (!fromBackdrop || ev.target !== ev.currentTarget) return;
|
|
1011
|
+
if (props.darkClickClose && state.view === 3) {
|
|
823
1012
|
hide();
|
|
824
1013
|
}
|
|
825
1014
|
};
|
|
@@ -827,6 +1016,7 @@
|
|
|
827
1016
|
// 监听动画
|
|
828
1017
|
const anim = (ev?: AnimationEvent) => {
|
|
829
1018
|
if (ev && ev.target !== ev.currentTarget) return;
|
|
1019
|
+
clearAnimTimer();
|
|
830
1020
|
if (state.view === 1) {
|
|
831
1021
|
if (state.parkAfterHide) {
|
|
832
1022
|
state.parkAfterHide = false;
|
|
@@ -834,11 +1024,14 @@
|
|
|
834
1024
|
return;
|
|
835
1025
|
}
|
|
836
1026
|
state.view = 0;
|
|
837
|
-
|
|
1027
|
+
syncTaskView(0);
|
|
838
1028
|
onGroupLayerClosed(id);
|
|
1029
|
+
runCallbacks('hide');
|
|
1030
|
+
return;
|
|
839
1031
|
}
|
|
840
1032
|
if (state.view === 2) {
|
|
841
1033
|
state.view = 3;
|
|
1034
|
+
syncTaskView(3);
|
|
842
1035
|
runCallbacks('show');
|
|
843
1036
|
}
|
|
844
1037
|
};
|
|
@@ -849,7 +1042,7 @@
|
|
|
849
1042
|
console.error('Layer 有重复 id', id);
|
|
850
1043
|
}
|
|
851
1044
|
if (id) {
|
|
852
|
-
|
|
1045
|
+
self = {
|
|
853
1046
|
dark: props.dark,
|
|
854
1047
|
darkClickClose: props.darkClickClose,
|
|
855
1048
|
escClose: props.escClose,
|
|
@@ -868,6 +1061,7 @@
|
|
|
868
1061
|
unpark,
|
|
869
1062
|
view: state.view,
|
|
870
1063
|
};
|
|
1064
|
+
_tasks[id] = self;
|
|
871
1065
|
ensureEscapeListener();
|
|
872
1066
|
} else {
|
|
873
1067
|
console.error('Layer 必须填写全局统一的 id');
|
|
@@ -875,7 +1069,11 @@
|
|
|
875
1069
|
|
|
876
1070
|
// 卸载弹层
|
|
877
1071
|
onBeforeUnmount(() => {
|
|
878
|
-
|
|
1072
|
+
clearAnimTimer();
|
|
1073
|
+
cancelPendingShow();
|
|
1074
|
+
// 同 id 已被新实例接管(路由过渡/HMR 时新实例先挂载):只释放本实例私有资源,不动全局注册与按 id 记录的栈/队列/暗层
|
|
1075
|
+
const owns = ownsRegistration();
|
|
1076
|
+
if (owns && participatesInGroup(props.dark) && id) {
|
|
879
1077
|
const group = getAreaGroup(props.area);
|
|
880
1078
|
removeFromQueue(group, id);
|
|
881
1079
|
if (state.view > 0) {
|
|
@@ -885,10 +1083,14 @@
|
|
|
885
1083
|
} else {
|
|
886
1084
|
removeFromStack(group, id);
|
|
887
1085
|
}
|
|
1086
|
+
// 卸载后 view watcher 可能不再执行,显式释放暗层占用
|
|
1087
|
+
releaseGlobalDark(props.area, group, id, true);
|
|
1088
|
+
}
|
|
1089
|
+
if (owns) {
|
|
1090
|
+
removeEscapeStack(id);
|
|
1091
|
+
delete _tasks[id];
|
|
888
1092
|
}
|
|
889
|
-
removeEscapeStack(id);
|
|
890
1093
|
releaseScrollLock(scrollLockOwner);
|
|
891
|
-
delete _tasks[id];
|
|
892
1094
|
});
|
|
893
1095
|
</script>
|
|
894
1096
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div ui-date="@a" class="fekit-date-view pr" :class="`${frame ? 'b-solid b-line b-xs r-sm' : ''}`" ui-flex="col xy" style="width: 22em">
|
|
2
|
+
<div ui-date="@a" class="mob:min-w-full fekit-date-view pr" :class="`${frame ? 'b-solid b-line b-xs r-sm' : ''}`" ui-flex="col xy" style="width: 22em">
|
|
3
3
|
<div ui-date-head="" class="flex-fixed nx-ms mt-ss ny-sm b-solid b-line" ui-flex="row xm">
|
|
4
4
|
<div ui-flex="row lm" class="o-lm">
|
|
5
5
|
<div ui-btn="@a s none :square" @click="prev()">
|
|
@@ -60,6 +60,9 @@
|
|
|
60
60
|
</div>
|
|
61
61
|
</li>
|
|
62
62
|
</ul>
|
|
63
|
+
<div v-if="$slots.footer" class="nx-sm ny-ss">
|
|
64
|
+
<slot name="footer" :close="closeDropdown"></slot>
|
|
65
|
+
</div>
|
|
63
66
|
</template>
|
|
64
67
|
</IFollowView>
|
|
65
68
|
</div>
|
|
@@ -72,6 +75,7 @@
|
|
|
72
75
|
import { matchOptionSearch } from './optionSearch';
|
|
73
76
|
const ifollow: any = ref(null);
|
|
74
77
|
const customInputRef: any = ref(null);
|
|
78
|
+
const closeDropdown = () => ifollow.value?.hide();
|
|
75
79
|
|
|
76
80
|
const emits = defineEmits(['update:modelValue', 'change', 'onshow', 'onhide', 'update:other']);
|
|
77
81
|
|
|
@@ -187,6 +191,8 @@
|
|
|
187
191
|
|
|
188
192
|
// 自定义输入值
|
|
189
193
|
const customInputValue = ref('');
|
|
194
|
+
// 选择其他后立即显示输入框,不依赖已有自定义值。
|
|
195
|
+
const typing = ref(false);
|
|
190
196
|
|
|
191
197
|
const state = reactive({
|
|
192
198
|
show: 0,
|
|
@@ -194,9 +200,10 @@
|
|
|
194
200
|
isInputting: computed(() => {
|
|
195
201
|
// 如果配置了"其他"选项且当前选中的是"其他"
|
|
196
202
|
if (props.other) {
|
|
203
|
+
if (typing.value) return true;
|
|
197
204
|
if (props.other.field) {
|
|
198
|
-
// 情况2
|
|
199
|
-
return props.modelValue === props.other.value
|
|
205
|
+
// 情况2:独立字段可以尚未填写,主值选中其他即显示输入框。
|
|
206
|
+
return props.modelValue === props.other.value;
|
|
200
207
|
} else {
|
|
201
208
|
// 情况1:无独立字段,当值不在枚举中时显示输入框
|
|
202
209
|
if (props.modelValue && props.modelValue !== '') {
|
|
@@ -323,33 +330,36 @@
|
|
|
323
330
|
change('');
|
|
324
331
|
}
|
|
325
332
|
customInputValue.value = '';
|
|
333
|
+
typing.value = true;
|
|
326
334
|
nextTick(() => {
|
|
327
|
-
|
|
335
|
+
// Input 未暴露 focus 方法,聚焦其原生输入框。
|
|
336
|
+
customInputRef.value?.$el?.querySelector?.('input')?.focus();
|
|
328
337
|
});
|
|
329
338
|
} else {
|
|
339
|
+
typing.value = false;
|
|
330
340
|
change(item.value);
|
|
331
|
-
|
|
341
|
+
closeDropdown();
|
|
332
342
|
}
|
|
333
343
|
};
|
|
334
344
|
|
|
335
|
-
//
|
|
345
|
+
// 确认自定义输入(回车与失焦都会触发,需保持幂等,避免重复派发 change)
|
|
336
346
|
const confirmCustomInput = () => {
|
|
337
|
-
|
|
347
|
+
const text = customInputValue.value.trim();
|
|
348
|
+
if (text) {
|
|
338
349
|
if (props.other?.field) {
|
|
339
350
|
// 如果指定了 field,则主字段存储选项值,field 存储自定义输入
|
|
340
|
-
change(props.other.value);
|
|
341
|
-
emits('update:other',
|
|
342
|
-
} else {
|
|
351
|
+
if (props.modelValue !== props.other.value) change(props.other.value);
|
|
352
|
+
if (props.otherValue !== text) emits('update:other', text);
|
|
353
|
+
} else if (props.modelValue !== text) {
|
|
343
354
|
// 如果没有指定 field,则直接在主字段存储自定义输入
|
|
344
|
-
change(
|
|
345
|
-
}
|
|
346
|
-
ifollow.value.cancel();
|
|
347
|
-
} else {
|
|
348
|
-
// 如果输入为空,清空值
|
|
349
|
-
change(null);
|
|
350
|
-
if (props.other?.field) {
|
|
351
|
-
emits('update:other', '');
|
|
355
|
+
change(text);
|
|
352
356
|
}
|
|
357
|
+
closeDropdown();
|
|
358
|
+
} else if (!props.other?.field) {
|
|
359
|
+
// 无独立字段时空输入即无值;有独立字段时保留已选的“其他”,等待补填。
|
|
360
|
+
if (props.modelValue != null && props.modelValue !== '') change(null);
|
|
361
|
+
} else if (props.otherValue) {
|
|
362
|
+
emits('update:other', '');
|
|
353
363
|
}
|
|
354
364
|
};
|
|
355
365
|
|
|
@@ -378,8 +388,9 @@
|
|
|
378
388
|
};
|
|
379
389
|
// 清空
|
|
380
390
|
const remove = () => {
|
|
391
|
+
typing.value = false;
|
|
381
392
|
change(null);
|
|
382
|
-
|
|
393
|
+
closeDropdown();
|
|
383
394
|
};
|
|
384
395
|
// 取消
|
|
385
396
|
const cancel = () => {
|
|
@@ -392,6 +403,7 @@
|
|
|
392
403
|
// 折叠
|
|
393
404
|
const onhide = () => {
|
|
394
405
|
state.search = '';
|
|
406
|
+
typing.value = false;
|
|
395
407
|
emits('onhide', state.selectd);
|
|
396
408
|
};
|
|
397
409
|
|