@veltra/utils 1.4.0 → 1.6.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.
- package/dist/dom/expand-transition.d.ts +7 -2
- package/dist/dom/expand-transition.js +17 -10
- package/dist/dom/expand-transition.js.map +1 -1
- package/dist/dom/position.d.ts +3 -2
- package/dist/dom/position.js +5 -4
- package/dist/dom/position.js.map +1 -1
- package/dist/helper/overflow-nav.d.ts +41 -0
- package/dist/helper/overflow-nav.js +45 -0
- package/dist/helper/overflow-nav.js.map +1 -0
- package/dist/helper/tween.js +2 -2
- package/dist/helper/tween.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/package.json +2 -2
- package/src/dom/__test__/expand-transition.test.ts +51 -1
- package/src/dom/expand-transition.ts +17 -10
- package/src/dom/position.ts +5 -4
- package/src/helper/overflow-nav.ts +71 -0
- package/src/index.ts +2 -0
|
@@ -15,8 +15,13 @@ declare class ExpandTransition {
|
|
|
15
15
|
beforeLeave(el: HTMLElement): void;
|
|
16
16
|
leave(el: HTMLElement): void;
|
|
17
17
|
afterLeave(el: HTMLElement): void;
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* 展开动画;`onEnd` 在动画落定(含无需动画直接落定)后调用,
|
|
20
|
+
* 被新的动画打断时不调用
|
|
21
|
+
*/
|
|
22
|
+
expand(el: HTMLElement, onEnd?: () => void): void;
|
|
23
|
+
/** 收起动画;`onEnd` 语义同 `expand` */
|
|
24
|
+
collapse(el: HTMLElement, onEnd?: () => void): void;
|
|
20
25
|
setExpanded(el: HTMLElement, expanded: boolean): void;
|
|
21
26
|
private animate;
|
|
22
27
|
private resetTransitionStyles;
|
|
@@ -69,11 +69,16 @@ var ExpandTransition = class {
|
|
|
69
69
|
afterLeave(el) {
|
|
70
70
|
this.resetTransitionStyles(el);
|
|
71
71
|
}
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
/**
|
|
73
|
+
* 展开动画;`onEnd` 在动画落定(含无需动画直接落定)后调用,
|
|
74
|
+
* 被新的动画打断时不调用
|
|
75
|
+
*/
|
|
76
|
+
expand(el, onEnd) {
|
|
77
|
+
this.animate(el, true, onEnd);
|
|
74
78
|
}
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
/** 收起动画;`onEnd` 语义同 `expand` */
|
|
80
|
+
collapse(el, onEnd) {
|
|
81
|
+
this.animate(el, false, onEnd);
|
|
77
82
|
}
|
|
78
83
|
setExpanded(el, expanded) {
|
|
79
84
|
this.cancel(el);
|
|
@@ -81,13 +86,14 @@ var ExpandTransition = class {
|
|
|
81
86
|
el.style.overflow = "hidden";
|
|
82
87
|
el.style.height = expanded ? "auto" : "0px";
|
|
83
88
|
}
|
|
84
|
-
animate(el, expanded) {
|
|
89
|
+
animate(el, expanded, onEnd) {
|
|
85
90
|
this.cancel(el);
|
|
86
91
|
const startHeight = el.offsetHeight;
|
|
87
92
|
const endHeight = expanded ? el.scrollHeight : 0;
|
|
88
93
|
const { paddingTop, paddingBottom } = readVerticalPadding(el);
|
|
89
94
|
if (startHeight === endHeight) {
|
|
90
95
|
this.setExpanded(el, expanded);
|
|
96
|
+
onEnd?.();
|
|
91
97
|
return;
|
|
92
98
|
}
|
|
93
99
|
setStyles(el, {
|
|
@@ -106,19 +112,20 @@ var ExpandTransition = class {
|
|
|
106
112
|
paddingBottom: expanded ? paddingBottom : 0
|
|
107
113
|
});
|
|
108
114
|
const cleanup = () => {
|
|
109
|
-
el.removeEventListener("transitionend",
|
|
110
|
-
el.removeEventListener("transitioncancel",
|
|
115
|
+
el.removeEventListener("transitionend", onEndHandler);
|
|
116
|
+
el.removeEventListener("transitioncancel", onEndHandler);
|
|
111
117
|
this.cleanupMap.delete(el);
|
|
112
118
|
};
|
|
113
|
-
const
|
|
119
|
+
const onEndHandler = (e) => {
|
|
114
120
|
if (e.target !== el || e.propertyName !== "height") return;
|
|
115
121
|
cleanup();
|
|
116
122
|
this.resetTemporaryStyles(el);
|
|
117
123
|
el.style.overflow = "hidden";
|
|
118
124
|
el.style.height = expanded ? "auto" : "0px";
|
|
125
|
+
onEnd?.();
|
|
119
126
|
};
|
|
120
|
-
el.addEventListener("transitionend",
|
|
121
|
-
el.addEventListener("transitioncancel",
|
|
127
|
+
el.addEventListener("transitionend", onEndHandler);
|
|
128
|
+
el.addEventListener("transitioncancel", onEndHandler);
|
|
122
129
|
this.cleanupMap.set(el, cleanup);
|
|
123
130
|
}
|
|
124
131
|
resetTransitionStyles(el) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"expand-transition.js","names":[],"sources":["../../src/dom/expand-transition.ts"],"sourcesContent":["import { removeStyles, setStyles } from './style'\n\nexport interface ExpandTransitionOptions {\n transition?: string\n enterTransition?: string\n leaveTransition?: string\n opacity?: boolean\n}\n\nfunction readVerticalPadding(el: HTMLElement) {\n const { paddingTop, paddingBottom } = getComputedStyle(el)\n return { paddingTop, paddingBottom }\n}\n\nfunction getTransition(options: ExpandTransitionOptions, type: 'enter' | 'leave') {\n return (\n (type === 'enter' ? options.enterTransition : options.leaveTransition) ??\n options.transition ??\n 'height 0.25s cubic-bezier(0.4, 0, 0.2, 1)'\n )\n}\n\nexport class ExpandTransition {\n private cleanupMap = new Map<HTMLElement, () => void>()\n\n constructor(private options: ExpandTransitionOptions = {}) {}\n\n cancel(el: HTMLElement) {\n this.cleanupMap.get(el)?.()\n this.cleanupMap.delete(el)\n }\n\n enter(el: HTMLElement) {\n const height = el.scrollHeight\n const { paddingTop, paddingBottom } = readVerticalPadding(el)\n\n setStyles(el, {\n boxSizing: 'border-box',\n height: 0,\n paddingTop: 0,\n paddingBottom: 0,\n overflow: 'hidden',\n transition: getTransition(this.options, 'enter'),\n willChange: this.options.opacity ? 'height, opacity' : 'height'\n })\n\n if (this.options.opacity) {\n el.style.opacity = '0'\n }\n\n void el.offsetHeight\n\n setStyles(el, { height: `${height}px`, paddingTop, paddingBottom })\n\n if (this.options.opacity) {\n el.style.opacity = '1'\n }\n }\n\n afterEnter(el: HTMLElement) {\n this.resetTransitionStyles(el)\n }\n\n beforeLeave(el: HTMLElement) {\n const { paddingTop, paddingBottom } = readVerticalPadding(el)\n\n setStyles(el, {\n boxSizing: 'border-box',\n height: `${el.scrollHeight}px`,\n paddingTop,\n paddingBottom,\n overflow: 'hidden',\n transition: getTransition(this.options, 'leave'),\n willChange: this.options.opacity ? 'height, opacity' : 'height'\n })\n\n if (this.options.opacity) {\n el.style.opacity = '1'\n }\n }\n\n leave(el: HTMLElement) {\n void el.offsetHeight\n\n setStyles(el, { height: 0, paddingTop: 0, paddingBottom: 0 })\n\n if (this.options.opacity) {\n el.style.opacity = '0'\n }\n }\n\n afterLeave(el: HTMLElement) {\n this.resetTransitionStyles(el)\n }\n\n expand(el: HTMLElement) {\n this.animate(el, true)\n }\n\n collapse(el: HTMLElement) {\n this.animate(el, false)\n }\n\n setExpanded(el: HTMLElement, expanded: boolean) {\n this.cancel(el)\n this.resetTemporaryStyles(el)\n el.style.overflow = 'hidden'\n el.style.height = expanded ? 'auto' : '0px'\n }\n\n private animate(el: HTMLElement, expanded: boolean) {\n this.cancel(el)\n\n const startHeight = el.offsetHeight\n const endHeight = expanded ? el.scrollHeight : 0\n const { paddingTop, paddingBottom } = readVerticalPadding(el)\n\n if (startHeight === endHeight) {\n this.setExpanded(el, expanded)\n return\n }\n\n setStyles(el, {\n boxSizing: 'border-box',\n height: `${startHeight}px`,\n paddingTop,\n paddingBottom,\n overflow: 'hidden',\n transition: getTransition(this.options, expanded ? 'enter' : 'leave'),\n willChange: 'height'\n })\n\n void el.offsetHeight\n\n setStyles(el, {\n height: `${endHeight}px`,\n paddingTop: expanded ? paddingTop : 0,\n paddingBottom: expanded ? paddingBottom : 0\n })\n\n const cleanup = () => {\n el.removeEventListener('transitionend',
|
|
1
|
+
{"version":3,"file":"expand-transition.js","names":[],"sources":["../../src/dom/expand-transition.ts"],"sourcesContent":["import { removeStyles, setStyles } from './style'\n\nexport interface ExpandTransitionOptions {\n transition?: string\n enterTransition?: string\n leaveTransition?: string\n opacity?: boolean\n}\n\nfunction readVerticalPadding(el: HTMLElement) {\n const { paddingTop, paddingBottom } = getComputedStyle(el)\n return { paddingTop, paddingBottom }\n}\n\nfunction getTransition(options: ExpandTransitionOptions, type: 'enter' | 'leave') {\n return (\n (type === 'enter' ? options.enterTransition : options.leaveTransition) ??\n options.transition ??\n 'height 0.25s cubic-bezier(0.4, 0, 0.2, 1)'\n )\n}\n\nexport class ExpandTransition {\n private cleanupMap = new Map<HTMLElement, () => void>()\n\n constructor(private options: ExpandTransitionOptions = {}) {}\n\n cancel(el: HTMLElement) {\n this.cleanupMap.get(el)?.()\n this.cleanupMap.delete(el)\n }\n\n enter(el: HTMLElement) {\n const height = el.scrollHeight\n const { paddingTop, paddingBottom } = readVerticalPadding(el)\n\n setStyles(el, {\n boxSizing: 'border-box',\n height: 0,\n paddingTop: 0,\n paddingBottom: 0,\n overflow: 'hidden',\n transition: getTransition(this.options, 'enter'),\n willChange: this.options.opacity ? 'height, opacity' : 'height'\n })\n\n if (this.options.opacity) {\n el.style.opacity = '0'\n }\n\n void el.offsetHeight\n\n setStyles(el, { height: `${height}px`, paddingTop, paddingBottom })\n\n if (this.options.opacity) {\n el.style.opacity = '1'\n }\n }\n\n afterEnter(el: HTMLElement) {\n this.resetTransitionStyles(el)\n }\n\n beforeLeave(el: HTMLElement) {\n const { paddingTop, paddingBottom } = readVerticalPadding(el)\n\n setStyles(el, {\n boxSizing: 'border-box',\n height: `${el.scrollHeight}px`,\n paddingTop,\n paddingBottom,\n overflow: 'hidden',\n transition: getTransition(this.options, 'leave'),\n willChange: this.options.opacity ? 'height, opacity' : 'height'\n })\n\n if (this.options.opacity) {\n el.style.opacity = '1'\n }\n }\n\n leave(el: HTMLElement) {\n void el.offsetHeight\n\n setStyles(el, { height: 0, paddingTop: 0, paddingBottom: 0 })\n\n if (this.options.opacity) {\n el.style.opacity = '0'\n }\n }\n\n afterLeave(el: HTMLElement) {\n this.resetTransitionStyles(el)\n }\n\n /**\n * 展开动画;`onEnd` 在动画落定(含无需动画直接落定)后调用,\n * 被新的动画打断时不调用\n */\n expand(el: HTMLElement, onEnd?: () => void) {\n this.animate(el, true, onEnd)\n }\n\n /** 收起动画;`onEnd` 语义同 `expand` */\n collapse(el: HTMLElement, onEnd?: () => void) {\n this.animate(el, false, onEnd)\n }\n\n setExpanded(el: HTMLElement, expanded: boolean) {\n this.cancel(el)\n this.resetTemporaryStyles(el)\n el.style.overflow = 'hidden'\n el.style.height = expanded ? 'auto' : '0px'\n }\n\n private animate(el: HTMLElement, expanded: boolean, onEnd?: () => void) {\n this.cancel(el)\n\n const startHeight = el.offsetHeight\n const endHeight = expanded ? el.scrollHeight : 0\n const { paddingTop, paddingBottom } = readVerticalPadding(el)\n\n if (startHeight === endHeight) {\n this.setExpanded(el, expanded)\n onEnd?.()\n return\n }\n\n setStyles(el, {\n boxSizing: 'border-box',\n height: `${startHeight}px`,\n paddingTop,\n paddingBottom,\n overflow: 'hidden',\n transition: getTransition(this.options, expanded ? 'enter' : 'leave'),\n willChange: 'height'\n })\n\n void el.offsetHeight\n\n setStyles(el, {\n height: `${endHeight}px`,\n paddingTop: expanded ? paddingTop : 0,\n paddingBottom: expanded ? paddingBottom : 0\n })\n\n const cleanup = () => {\n el.removeEventListener('transitionend', onEndHandler)\n el.removeEventListener('transitioncancel', onEndHandler)\n this.cleanupMap.delete(el)\n }\n\n const onEndHandler = (e: TransitionEvent) => {\n if (e.target !== el || e.propertyName !== 'height') return\n cleanup()\n this.resetTemporaryStyles(el)\n el.style.overflow = 'hidden'\n el.style.height = expanded ? 'auto' : '0px'\n onEnd?.()\n }\n\n el.addEventListener('transitionend', onEndHandler)\n el.addEventListener('transitioncancel', onEndHandler)\n this.cleanupMap.set(el, cleanup)\n }\n\n private resetTransitionStyles(el: HTMLElement) {\n removeStyles(el, [\n 'box-sizing',\n 'height',\n 'padding-top',\n 'padding-bottom',\n 'overflow',\n 'transition',\n 'opacity',\n 'will-change'\n ])\n }\n\n private resetTemporaryStyles(el: HTMLElement) {\n removeStyles(el, [\n 'box-sizing',\n 'padding-top',\n 'padding-bottom',\n 'transition',\n 'opacity',\n 'will-change'\n ])\n }\n}\n"],"mappings":"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"}
|
package/dist/dom/position.d.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
//#region src/dom/position.d.ts
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* 获取可滚动的父级(纵向或横向可滚动均计入:overflow-x 容器如横向滚动条
|
|
4
|
+
* 也是滚动父级,弹层需监听其 scroll 事件)
|
|
4
5
|
* @param el 元素
|
|
5
6
|
* @returns
|
|
6
7
|
*/
|
|
7
8
|
declare function getScrollParents(el: HTMLElement): HTMLElement[];
|
|
8
9
|
/**
|
|
9
|
-
*
|
|
10
|
+
* 获取最近的可滚动父级(纵向或横向可滚动均计入)
|
|
10
11
|
* @param el 元素
|
|
11
12
|
* @returns 最近的可滚动父级
|
|
12
13
|
*/
|
package/dist/dom/position.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
//#region src/dom/position.ts
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* 获取可滚动的父级(纵向或横向可滚动均计入:overflow-x 容器如横向滚动条
|
|
4
|
+
* 也是滚动父级,弹层需监听其 scroll 事件)
|
|
4
5
|
* @param el 元素
|
|
5
6
|
* @returns
|
|
6
7
|
*/
|
|
@@ -8,20 +9,20 @@ function getScrollParents(el) {
|
|
|
8
9
|
const parents = [];
|
|
9
10
|
let parent = el.parentElement;
|
|
10
11
|
while (parent) {
|
|
11
|
-
if (parent.scrollHeight > parent.clientHeight) parents.push(parent);
|
|
12
|
+
if (parent.scrollHeight > parent.clientHeight || parent.scrollWidth > parent.clientWidth) parents.push(parent);
|
|
12
13
|
parent = parent.parentElement;
|
|
13
14
|
}
|
|
14
15
|
return parents;
|
|
15
16
|
}
|
|
16
17
|
/**
|
|
17
|
-
*
|
|
18
|
+
* 获取最近的可滚动父级(纵向或横向可滚动均计入)
|
|
18
19
|
* @param el 元素
|
|
19
20
|
* @returns 最近的可滚动父级
|
|
20
21
|
*/
|
|
21
22
|
function getNearestScrollParent(el) {
|
|
22
23
|
let parent = el.parentElement;
|
|
23
24
|
while (parent) {
|
|
24
|
-
if (parent.scrollHeight > parent.clientHeight) return parent;
|
|
25
|
+
if (parent.scrollHeight > parent.clientHeight || parent.scrollWidth > parent.clientWidth) return parent;
|
|
25
26
|
parent = parent.parentElement;
|
|
26
27
|
}
|
|
27
28
|
return null;
|
package/dist/dom/position.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"position.js","names":[],"sources":["../../src/dom/position.ts"],"sourcesContent":["/**\n *
|
|
1
|
+
{"version":3,"file":"position.js","names":[],"sources":["../../src/dom/position.ts"],"sourcesContent":["/**\n * 获取可滚动的父级(纵向或横向可滚动均计入:overflow-x 容器如横向滚动条\n * 也是滚动父级,弹层需监听其 scroll 事件)\n * @param el 元素\n * @returns\n */\nexport function getScrollParents(el: HTMLElement): HTMLElement[] {\n const parents: HTMLElement[] = []\n let parent = el.parentElement\n while (parent) {\n if (parent.scrollHeight > parent.clientHeight || parent.scrollWidth > parent.clientWidth) {\n parents.push(parent)\n }\n\n parent = parent.parentElement\n }\n return parents\n}\n\n/**\n * 获取最近的可滚动父级(纵向或横向可滚动均计入)\n * @param el 元素\n * @returns 最近的可滚动父级\n */\nexport function getNearestScrollParent(el: HTMLElement): HTMLElement | null {\n let parent = el.parentElement\n while (parent) {\n if (parent.scrollHeight > parent.clientHeight || parent.scrollWidth > parent.clientWidth) {\n return parent\n }\n parent = parent.parentElement\n }\n return null\n}\n\ntype ScrollViewPosition = 'center' | 'start' | 'end'\n\n/**\n * 滚动元素到容器视图中\n * @description 用于替代 `el.scrollIntoView` 方法,因为 `el.scrollIntoView` 在某些情况下会导致外部元素滚动\n * @param el 元素\n * @param container 可滚动容器\n * @param options 滚动选项\n */\nexport function scrollIntoContainerView(\n el: HTMLElement,\n container: HTMLElement | null,\n options?: { block?: ScrollViewPosition; inline?: ScrollViewPosition }\n): void {\n container = container || getNearestScrollParent(el)\n if (!container) return\n\n const { block = 'center', inline = 'center' } = options || {}\n\n const {\n offsetTop: eOffsetTop,\n offsetLeft: eOffsetLeft,\n offsetHeight: eOffsetHeight,\n offsetWidth: eOffsetWidth\n } = el\n\n const {\n clientHeight: cClientHeight,\n clientWidth: cClientWidth,\n scrollTop: cScrollTop,\n scrollLeft: cScrollLeft\n } = container\n\n const isVerticalInView =\n cScrollTop + cClientHeight > eOffsetTop + eOffsetHeight && cScrollTop < eOffsetTop\n\n const isHorizontalInView =\n cScrollLeft + cClientWidth > eOffsetLeft + eOffsetWidth && cScrollLeft < eOffsetLeft\n\n // 垂直方向和水平方向都已经在视图中,则不进行滚动\n if (isVerticalInView && isHorizontalInView) return\n\n if (!isVerticalInView) {\n if (block === 'center') {\n container.scrollTop = eOffsetTop - cClientHeight / 2 + eOffsetHeight / 2\n } else if (block === 'start') {\n container.scrollTop = eOffsetTop\n } else if (block === 'end') {\n container.scrollTop = eOffsetTop - cClientHeight + eOffsetHeight\n }\n }\n\n if (!isHorizontalInView) {\n if (inline === 'center') {\n container.scrollLeft = eOffsetLeft - cClientWidth / 2 + eOffsetWidth / 2\n } else if (inline === 'start') {\n container.scrollLeft = eOffsetLeft\n } else if (inline === 'end') {\n container.scrollLeft = eOffsetLeft - cClientWidth + eOffsetWidth\n }\n }\n}\n"],"mappings":";;;;;;;AAMA,SAAgB,iBAAiB,IAAgC;CAC/D,MAAM,UAAyB,CAAC;CAChC,IAAI,SAAS,GAAG;CAChB,OAAO,QAAQ;EACb,IAAI,OAAO,eAAe,OAAO,gBAAgB,OAAO,cAAc,OAAO,aAC3E,QAAQ,KAAK,MAAM;EAGrB,SAAS,OAAO;CAClB;CACA,OAAO;AACT;;;;;;AAOA,SAAgB,uBAAuB,IAAqC;CAC1E,IAAI,SAAS,GAAG;CAChB,OAAO,QAAQ;EACb,IAAI,OAAO,eAAe,OAAO,gBAAgB,OAAO,cAAc,OAAO,aAC3E,OAAO;EAET,SAAS,OAAO;CAClB;CACA,OAAO;AACT;;;;;;;;AAWA,SAAgB,wBACd,IACA,WACA,SACM;CACN,YAAY,aAAa,uBAAuB,EAAE;CAClD,IAAI,CAAC,WAAW;CAEhB,MAAM,EAAE,QAAQ,UAAU,SAAS,aAAa,WAAW,CAAC;CAE5D,MAAM,EACJ,WAAW,YACX,YAAY,aACZ,cAAc,eACd,aAAa,iBACX;CAEJ,MAAM,EACJ,cAAc,eACd,aAAa,cACb,WAAW,YACX,YAAY,gBACV;CAEJ,MAAM,mBACJ,aAAa,gBAAgB,aAAa,iBAAiB,aAAa;CAE1E,MAAM,qBACJ,cAAc,eAAe,cAAc,gBAAgB,cAAc;CAG3E,IAAI,oBAAoB,oBAAoB;CAE5C,IAAI,CAAC,kBACC;MAAA,UAAU,UACZ,UAAU,YAAY,aAAa,gBAAgB,IAAI,gBAAgB;OAClE,IAAI,UAAU,SACnB,UAAU,YAAY;OACjB,IAAI,UAAU,OACnB,UAAU,YAAY,aAAa,gBAAgB;CAAA;CAIvD,IAAI,CAAC,oBACC;MAAA,WAAW,UACb,UAAU,aAAa,cAAc,eAAe,IAAI,eAAe;OAClE,IAAI,WAAW,SACpB,UAAU,aAAa;OAClB,IAAI,WAAW,OACpB,UAAU,aAAa,cAAc,eAAe;CAAA;AAG1D"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//#region src/helper/overflow-nav.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* 水平溢出滚动导航的共享纯逻辑(tabs / toolbar 三处复制的收敛,见 #17)。
|
|
4
|
+
* 只提供无 Vue 依赖的计算与副作用,事件绑定 / ResizeObserver / watch 由各
|
|
5
|
+
* composable 按需组合(三处的监听源与生命周期有真实差异,不强行统一)。
|
|
6
|
+
*/
|
|
7
|
+
/** 视口滚动几何(scrollTo 兼容 HTMLElement 与 vue 模板 ref) */
|
|
8
|
+
interface OverflowNavViewport {
|
|
9
|
+
scrollLeft: number;
|
|
10
|
+
scrollWidth: number;
|
|
11
|
+
clientWidth: number;
|
|
12
|
+
}
|
|
13
|
+
/** 导航按钮状态 */
|
|
14
|
+
interface OverflowNavState {
|
|
15
|
+
/** 内容是否溢出视口 */
|
|
16
|
+
overflowing: boolean;
|
|
17
|
+
/** 可向前(左/上)滚动 */
|
|
18
|
+
canPrev: boolean;
|
|
19
|
+
/** 可向后(右/下)滚动 */
|
|
20
|
+
canNext: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** 依据视口与内容尺寸推导滚动按钮可用状态 */
|
|
23
|
+
declare function computeOverflowNavState(vp: OverflowNavViewport): OverflowNavState;
|
|
24
|
+
type ScrollableElement = OverflowNavViewport & {
|
|
25
|
+
scrollTo(options: {
|
|
26
|
+
left: number;
|
|
27
|
+
behavior: 'smooth';
|
|
28
|
+
}): void;
|
|
29
|
+
};
|
|
30
|
+
/** 箭头按钮:按视口宽度 80% 的步长平滑滚动 */
|
|
31
|
+
declare function scrollViewportByStep(vp: ScrollableElement, dir: 1 | -1): void;
|
|
32
|
+
/** 目标元素滚入视口(左/右越界时对齐到边缘,留 offset 边距) */
|
|
33
|
+
declare function scrollElementIntoView(vp: HTMLElement, el: HTMLElement, offset?: number): void;
|
|
34
|
+
/**
|
|
35
|
+
* 鼠标滚轮(纵向)驱动水平滚动;触控板横滑(已有 deltaX)不拦截。
|
|
36
|
+
* navActive = 导航按钮是否可见(内容未溢出时不消费滚轮)。
|
|
37
|
+
*/
|
|
38
|
+
declare function applyWheelHorizontalScroll(e: WheelEvent, vp: HTMLElement, navActive: boolean): void;
|
|
39
|
+
//#endregion
|
|
40
|
+
export { OverflowNavState, OverflowNavViewport, applyWheelHorizontalScroll, computeOverflowNavState, scrollElementIntoView, scrollViewportByStep };
|
|
41
|
+
//# sourceMappingURL=overflow-nav.d.ts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//#region src/helper/overflow-nav.ts
|
|
2
|
+
/** 依据视口与内容尺寸推导滚动按钮可用状态 */
|
|
3
|
+
function computeOverflowNavState(vp) {
|
|
4
|
+
const overflowing = vp.scrollWidth - vp.clientWidth > 1;
|
|
5
|
+
return {
|
|
6
|
+
overflowing,
|
|
7
|
+
canPrev: overflowing && vp.scrollLeft > 0,
|
|
8
|
+
canNext: overflowing && vp.scrollLeft + vp.clientWidth < vp.scrollWidth - 1
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** 箭头按钮:按视口宽度 80% 的步长平滑滚动 */
|
|
12
|
+
function scrollViewportByStep(vp, dir) {
|
|
13
|
+
vp.scrollTo({
|
|
14
|
+
left: vp.scrollLeft + dir * vp.clientWidth * .8,
|
|
15
|
+
behavior: "smooth"
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** 目标元素滚入视口(左/右越界时对齐到边缘,留 offset 边距) */
|
|
19
|
+
function scrollElementIntoView(vp, el, offset = 8) {
|
|
20
|
+
const vpRect = vp.getBoundingClientRect();
|
|
21
|
+
const elRect = el.getBoundingClientRect();
|
|
22
|
+
if (elRect.left < vpRect.left) vp.scrollTo({
|
|
23
|
+
left: vp.scrollLeft + (elRect.left - vpRect.left) - offset,
|
|
24
|
+
behavior: "smooth"
|
|
25
|
+
});
|
|
26
|
+
else if (elRect.right > vpRect.right) vp.scrollTo({
|
|
27
|
+
left: vp.scrollLeft + (elRect.right - vpRect.right) + offset,
|
|
28
|
+
behavior: "smooth"
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 鼠标滚轮(纵向)驱动水平滚动;触控板横滑(已有 deltaX)不拦截。
|
|
33
|
+
* navActive = 导航按钮是否可见(内容未溢出时不消费滚轮)。
|
|
34
|
+
*/
|
|
35
|
+
function applyWheelHorizontalScroll(e, vp, navActive) {
|
|
36
|
+
if (!navActive) return;
|
|
37
|
+
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
|
|
38
|
+
if (e.deltaY === 0) return;
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
vp.scrollLeft += e.deltaY;
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
export { applyWheelHorizontalScroll, computeOverflowNavState, scrollElementIntoView, scrollViewportByStep };
|
|
44
|
+
|
|
45
|
+
//# sourceMappingURL=overflow-nav.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"overflow-nav.js","names":[],"sources":["../../src/helper/overflow-nav.ts"],"sourcesContent":["/**\n * 水平溢出滚动导航的共享纯逻辑(tabs / toolbar 三处复制的收敛,见 #17)。\n * 只提供无 Vue 依赖的计算与副作用,事件绑定 / ResizeObserver / watch 由各\n * composable 按需组合(三处的监听源与生命周期有真实差异,不强行统一)。\n */\n\n/** 视口滚动几何(scrollTo 兼容 HTMLElement 与 vue 模板 ref) */\nexport interface OverflowNavViewport {\n scrollLeft: number\n scrollWidth: number\n clientWidth: number\n}\n\n/** 导航按钮状态 */\nexport interface OverflowNavState {\n /** 内容是否溢出视口 */\n overflowing: boolean\n /** 可向前(左/上)滚动 */\n canPrev: boolean\n /** 可向后(右/下)滚动 */\n canNext: boolean\n}\n\n/** 依据视口与内容尺寸推导滚动按钮可用状态 */\nexport function computeOverflowNavState(vp: OverflowNavViewport): OverflowNavState {\n const overflowing = vp.scrollWidth - vp.clientWidth > 1\n return {\n overflowing,\n canPrev: overflowing && vp.scrollLeft > 0,\n canNext: overflowing && vp.scrollLeft + vp.clientWidth < vp.scrollWidth - 1\n }\n}\n\ntype ScrollableElement = OverflowNavViewport & {\n scrollTo(options: { left: number; behavior: 'smooth' }): void\n}\n\n/** 箭头按钮:按视口宽度 80% 的步长平滑滚动 */\nexport function scrollViewportByStep(vp: ScrollableElement, dir: 1 | -1): void {\n vp.scrollTo({ left: vp.scrollLeft + dir * vp.clientWidth * 0.8, behavior: 'smooth' })\n}\n\n/** 目标元素滚入视口(左/右越界时对齐到边缘,留 offset 边距) */\nexport function scrollElementIntoView(vp: HTMLElement, el: HTMLElement, offset = 8): void {\n const vpRect = vp.getBoundingClientRect()\n const elRect = el.getBoundingClientRect()\n if (elRect.left < vpRect.left) {\n vp.scrollTo({ left: vp.scrollLeft + (elRect.left - vpRect.left) - offset, behavior: 'smooth' })\n } else if (elRect.right > vpRect.right) {\n vp.scrollTo({\n left: vp.scrollLeft + (elRect.right - vpRect.right) + offset,\n behavior: 'smooth'\n })\n }\n}\n\n/**\n * 鼠标滚轮(纵向)驱动水平滚动;触控板横滑(已有 deltaX)不拦截。\n * navActive = 导航按钮是否可见(内容未溢出时不消费滚轮)。\n */\nexport function applyWheelHorizontalScroll(\n e: WheelEvent,\n vp: HTMLElement,\n navActive: boolean\n): void {\n if (!navActive) return\n if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return\n if (e.deltaY === 0) return\n e.preventDefault()\n vp.scrollLeft += e.deltaY\n}\n"],"mappings":";;AAwBA,SAAgB,wBAAwB,IAA2C;CACjF,MAAM,cAAc,GAAG,cAAc,GAAG,cAAc;CACtD,OAAO;EACL;EACA,SAAS,eAAe,GAAG,aAAa;EACxC,SAAS,eAAe,GAAG,aAAa,GAAG,cAAc,GAAG,cAAc;CAC5E;AACF;;AAOA,SAAgB,qBAAqB,IAAuB,KAAmB;CAC7E,GAAG,SAAS;EAAE,MAAM,GAAG,aAAa,MAAM,GAAG,cAAc;EAAK,UAAU;CAAS,CAAC;AACtF;;AAGA,SAAgB,sBAAsB,IAAiB,IAAiB,SAAS,GAAS;CACxF,MAAM,SAAS,GAAG,sBAAsB;CACxC,MAAM,SAAS,GAAG,sBAAsB;CACxC,IAAI,OAAO,OAAO,OAAO,MACvB,GAAG,SAAS;EAAE,MAAM,GAAG,cAAc,OAAO,OAAO,OAAO,QAAQ;EAAQ,UAAU;CAAS,CAAC;MACzF,IAAI,OAAO,QAAQ,OAAO,OAC/B,GAAG,SAAS;EACV,MAAM,GAAG,cAAc,OAAO,QAAQ,OAAO,SAAS;EACtD,UAAU;CACZ,CAAC;AAEL;;;;;AAMA,SAAgB,2BACd,GACA,IACA,WACM;CACN,IAAI,CAAC,WAAW;CAChB,IAAI,KAAK,IAAI,EAAE,MAAM,IAAI,KAAK,IAAI,EAAE,MAAM,GAAG;CAC7C,IAAI,EAAE,WAAW,GAAG;CACpB,EAAE,eAAe;CACjB,GAAG,cAAc,EAAE;AACrB"}
|
package/dist/helper/tween.js
CHANGED
|
@@ -78,8 +78,8 @@ var Tween = class Tween {
|
|
|
78
78
|
easeInBack: (p) => p * p * (3.70158 * p - 1),
|
|
79
79
|
easeOutBack: (p) => 1 + 2.70158 * Math.pow(p - 1, 3) + 1.70158 * Math.pow(p - 1, 2),
|
|
80
80
|
easeInOutBack: (p) => {
|
|
81
|
-
const c2 =
|
|
82
|
-
return p < .5 ? Math.pow(2 * p, 2) * (
|
|
81
|
+
const c2 = 2.5949095;
|
|
82
|
+
return p < .5 ? Math.pow(2 * p, 2) * (7.189819 * p - c2) / 2 : (Math.pow(2 * p - 2, 2) * (3.5949095 * (p * 2 - 2) + c2) + 2) / 2;
|
|
83
83
|
}
|
|
84
84
|
};
|
|
85
85
|
};
|
package/dist/helper/tween.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tween.js","names":["pk"],"sources":["../../src/helper/tween.ts"],"sourcesContent":["/** 自 cat-kit 3.x Tween 行为对齐的轻量动画(原 `cat-kit/fe` 导出) */\n\nexport interface AnimeConfig<State extends Record<string, number>> {\n duration?: number\n easingFunction?: (progress: number) => number\n onComplete?(state: State): void\n}\n\nexport interface TweenConfig<State extends Record<string, number>> {\n duration?: number\n onUpdate?(state: State): void\n onComplete?(state: State): void\n easingFunction?: (progress: number) => number\n}\n\nexport class Tween<State extends Record<string, number> = Record<string, number>> {\n readonly state: State\n protected duration = 300\n protected onUpdate?: (state: State) => void\n protected onComplete?: (state: State) => void\n protected frameId?: number\n protected easingFunction: (progress: number) => number\n private defaultState: State\n\n constructor(state: State, config?: TweenConfig<State>) {\n this.state = state\n this.defaultState = { ...state }\n const { duration, onUpdate, onComplete, easingFunction } = config || {}\n if (duration !== undefined) this.duration = duration\n if (onUpdate !== undefined) this.onUpdate = onUpdate\n if (onComplete !== undefined) this.onComplete = onComplete\n this.easingFunction = easingFunction ?? Tween.easing.linear\n }\n\n protected raf(options: {\n onComplete: () => void\n duration: number\n tick: (p: number) => void\n }): void {\n const start = performance.now()\n const { onComplete, tick, duration } = options\n const update = (timestamp: number) => {\n const elapsed = timestamp - start\n const progress = Math.min(elapsed / duration, 1)\n tick(progress)\n if (progress < 1) {\n this.frameId = requestAnimationFrame(update)\n } else {\n tick(progress)\n this.stop()\n onComplete()\n }\n }\n this.frameId = requestAnimationFrame(update)\n }\n\n to(state: Partial<State>, config?: AnimeConfig<State>): void {\n this.stop()\n const prevState = { ...this.state }\n const stateDistance = Object.keys(state).reduce(\n (acc, key) => {\n const k = key as keyof State\n const sv = state[k] as number\n const pv = prevState[k] as number\n if ((sv || sv === 0) && (pv || pv === 0)) {\n acc[key] = sv - pv\n }\n return acc\n },\n {} as Record<string, number>\n )\n const duration = config?.duration || this.duration\n const easingFunction = config?.easingFunction || this.easingFunction\n const onComplete = config?.onComplete || this.onComplete\n this.raf({\n duration,\n onComplete: () => {\n for (const key in state) {\n if (key in this.state) {\n ;(this.state as Record<string, number>)[key] = state[key as keyof State] as number\n }\n }\n this.onUpdate?.(this.state)\n onComplete?.(this.state)\n },\n tick: (progress) => {\n for (const key in stateDistance) {\n const pk = key as keyof State\n const target = (prevState[pk] as number) + easingFunction(progress) * stateDistance[key]!\n ;(this.state as Record<string, number>)[key] = target\n }\n this.onUpdate?.(this.state)\n }\n })\n }\n\n back(config?: AnimeConfig<State>): void {\n this.to(this.defaultState as Partial<State>, config)\n }\n\n private stop(): boolean {\n if (!this.frameId) return false\n cancelAnimationFrame(this.frameId)\n this.frameId = undefined\n return true\n }\n\n static readonly easing = {\n linear: (p: number) => p,\n easeInQuad: (p: number) => p * p,\n easeOutQuad: (p: number) => p * (2 - p),\n easeInOutQuad: (p: number) => (p < 0.5 ? 2 * p * p : -1 + (4 - 2 * p) * p),\n easeInBack: (p: number) => p * p * ((2.70158 + 1) * p - 1),\n easeOutBack: (p: number) => 1 + 2.70158 * Math.pow(p - 1, 3) + 1.70158 * Math.pow(p - 1, 2),\n easeInOutBack: (p: number) => {\n const c1 = 1.70158\n const c2 = c1 * 1.525\n return p < 0.5\n ? (Math.pow(2 * p, 2) * ((c2 + 1) * 2 * p - c2)) / 2\n : (Math.pow(2 * p - 2, 2) * ((c2 + 1) * (p * 2 - 2) + c2) + 2) / 2\n }\n }\n}\n"],"mappings":";AAeA,IAAa,QAAb,MAAa,MAAqE;CAChF;CACA,WAAqB;CACrB;CACA;CACA;CACA;CACA;CAEA,YAAY,OAAc,QAA6B;EACrD,KAAK,QAAQ;EACb,KAAK,eAAe,EAAE,GAAG,MAAM;EAC/B,MAAM,EAAE,UAAU,UAAU,YAAY,mBAAmB,UAAU,CAAC;EACtE,IAAI,aAAa,KAAA,GAAW,KAAK,WAAW;EAC5C,IAAI,aAAa,KAAA,GAAW,KAAK,WAAW;EAC5C,IAAI,eAAe,KAAA,GAAW,KAAK,aAAa;EAChD,KAAK,iBAAiB,kBAAkB,MAAM,OAAO;CACvD;CAEA,IAAc,SAIL;EACP,MAAM,QAAQ,YAAY,IAAI;EAC9B,MAAM,EAAE,YAAY,MAAM,aAAa;EACvC,MAAM,UAAU,cAAsB;GACpC,MAAM,UAAU,YAAY;GAC5B,MAAM,WAAW,KAAK,IAAI,UAAU,UAAU,CAAC;GAC/C,KAAK,QAAQ;GACb,IAAI,WAAW,GACb,KAAK,UAAU,sBAAsB,MAAM;QACtC;IACL,KAAK,QAAQ;IACb,KAAK,KAAK;IACV,WAAW;GACb;EACF;EACA,KAAK,UAAU,sBAAsB,MAAM;CAC7C;CAEA,GAAG,OAAuB,QAAmC;EAC3D,KAAK,KAAK;EACV,MAAM,YAAY,EAAE,GAAG,KAAK,MAAM;EAClC,MAAM,gBAAgB,OAAO,KAAK,KAAK,CAAC,CAAC,QACtC,KAAK,QAAQ;GACZ,MAAM,IAAI;GACV,MAAM,KAAK,MAAM;GACjB,MAAM,KAAK,UAAU;GACrB,KAAK,MAAM,OAAO,OAAO,MAAM,OAAO,IACpC,IAAI,OAAO,KAAK;GAElB,OAAO;EACT,GACA,CAAC,CACH;EACA,MAAM,WAAW,QAAQ,YAAY,KAAK;EAC1C,MAAM,iBAAiB,QAAQ,kBAAkB,KAAK;EACtD,MAAM,aAAa,QAAQ,cAAc,KAAK;EAC9C,KAAK,IAAI;GACP;GACA,kBAAkB;IAChB,KAAK,MAAM,OAAO,OAChB,IAAI,OAAO,KAAK,OACb,KAAM,MAAiC,OAAO,MAAM;IAGzD,KAAK,WAAW,KAAK,KAAK;IAC1B,aAAa,KAAK,KAAK;GACzB;GACA,OAAO,aAAa;IAClB,KAAK,MAAM,OAAO,eAAe;KAE/B,MAAM,SAAU,UAAUA,OAAiB,eAAe,QAAQ,IAAI,cAAc;KACnF,KAAM,MAAiC,OAAO;IACjD;IACA,KAAK,WAAW,KAAK,KAAK;GAC5B;EACF,CAAC;CACH;CAEA,KAAK,QAAmC;EACtC,KAAK,GAAG,KAAK,cAAgC,MAAM;CACrD;CAEA,OAAwB;EACtB,IAAI,CAAC,KAAK,SAAS,OAAO;EAC1B,qBAAqB,KAAK,OAAO;EACjC,KAAK,UAAU,KAAA;EACf,OAAO;CACT;CAEA,OAAgB,SAAS;EACvB,SAAS,MAAc;EACvB,aAAa,MAAc,IAAI;EAC/B,cAAc,MAAc,KAAK,IAAI;EACrC,gBAAgB,MAAe,IAAI,KAAM,IAAI,IAAI,IAAI,MAAM,IAAI,IAAI,KAAK;EACxE,aAAa,MAAc,IAAI,KAAM,UAAe,IAAI;EACxD,cAAc,MAAc,IAAI,UAAU,KAAK,IAAI,IAAI,GAAG,CAAC,IAAI,UAAU,KAAK,IAAI,IAAI,GAAG,CAAC;EAC1F,gBAAgB,MAAc;GAE5B,MAAM,KAAK
|
|
1
|
+
{"version":3,"file":"tween.js","names":["pk"],"sources":["../../src/helper/tween.ts"],"sourcesContent":["/** 自 cat-kit 3.x Tween 行为对齐的轻量动画(原 `cat-kit/fe` 导出) */\n\nexport interface AnimeConfig<State extends Record<string, number>> {\n duration?: number\n easingFunction?: (progress: number) => number\n onComplete?(state: State): void\n}\n\nexport interface TweenConfig<State extends Record<string, number>> {\n duration?: number\n onUpdate?(state: State): void\n onComplete?(state: State): void\n easingFunction?: (progress: number) => number\n}\n\nexport class Tween<State extends Record<string, number> = Record<string, number>> {\n readonly state: State\n protected duration = 300\n protected onUpdate?: (state: State) => void\n protected onComplete?: (state: State) => void\n protected frameId?: number\n protected easingFunction: (progress: number) => number\n private defaultState: State\n\n constructor(state: State, config?: TweenConfig<State>) {\n this.state = state\n this.defaultState = { ...state }\n const { duration, onUpdate, onComplete, easingFunction } = config || {}\n if (duration !== undefined) this.duration = duration\n if (onUpdate !== undefined) this.onUpdate = onUpdate\n if (onComplete !== undefined) this.onComplete = onComplete\n this.easingFunction = easingFunction ?? Tween.easing.linear\n }\n\n protected raf(options: {\n onComplete: () => void\n duration: number\n tick: (p: number) => void\n }): void {\n const start = performance.now()\n const { onComplete, tick, duration } = options\n const update = (timestamp: number) => {\n const elapsed = timestamp - start\n const progress = Math.min(elapsed / duration, 1)\n tick(progress)\n if (progress < 1) {\n this.frameId = requestAnimationFrame(update)\n } else {\n tick(progress)\n this.stop()\n onComplete()\n }\n }\n this.frameId = requestAnimationFrame(update)\n }\n\n to(state: Partial<State>, config?: AnimeConfig<State>): void {\n this.stop()\n const prevState = { ...this.state }\n const stateDistance = Object.keys(state).reduce(\n (acc, key) => {\n const k = key as keyof State\n const sv = state[k] as number\n const pv = prevState[k] as number\n if ((sv || sv === 0) && (pv || pv === 0)) {\n acc[key] = sv - pv\n }\n return acc\n },\n {} as Record<string, number>\n )\n const duration = config?.duration || this.duration\n const easingFunction = config?.easingFunction || this.easingFunction\n const onComplete = config?.onComplete || this.onComplete\n this.raf({\n duration,\n onComplete: () => {\n for (const key in state) {\n if (key in this.state) {\n ;(this.state as Record<string, number>)[key] = state[key as keyof State] as number\n }\n }\n this.onUpdate?.(this.state)\n onComplete?.(this.state)\n },\n tick: (progress) => {\n for (const key in stateDistance) {\n const pk = key as keyof State\n const target = (prevState[pk] as number) + easingFunction(progress) * stateDistance[key]!\n ;(this.state as Record<string, number>)[key] = target\n }\n this.onUpdate?.(this.state)\n }\n })\n }\n\n back(config?: AnimeConfig<State>): void {\n this.to(this.defaultState as Partial<State>, config)\n }\n\n private stop(): boolean {\n if (!this.frameId) return false\n cancelAnimationFrame(this.frameId)\n this.frameId = undefined\n return true\n }\n\n static readonly easing = {\n linear: (p: number) => p,\n easeInQuad: (p: number) => p * p,\n easeOutQuad: (p: number) => p * (2 - p),\n easeInOutQuad: (p: number) => (p < 0.5 ? 2 * p * p : -1 + (4 - 2 * p) * p),\n easeInBack: (p: number) => p * p * ((2.70158 + 1) * p - 1),\n easeOutBack: (p: number) => 1 + 2.70158 * Math.pow(p - 1, 3) + 1.70158 * Math.pow(p - 1, 2),\n easeInOutBack: (p: number) => {\n const c1 = 1.70158\n const c2 = c1 * 1.525\n return p < 0.5\n ? (Math.pow(2 * p, 2) * ((c2 + 1) * 2 * p - c2)) / 2\n : (Math.pow(2 * p - 2, 2) * ((c2 + 1) * (p * 2 - 2) + c2) + 2) / 2\n }\n }\n}\n"],"mappings":";AAeA,IAAa,QAAb,MAAa,MAAqE;CAChF;CACA,WAAqB;CACrB;CACA;CACA;CACA;CACA;CAEA,YAAY,OAAc,QAA6B;EACrD,KAAK,QAAQ;EACb,KAAK,eAAe,EAAE,GAAG,MAAM;EAC/B,MAAM,EAAE,UAAU,UAAU,YAAY,mBAAmB,UAAU,CAAC;EACtE,IAAI,aAAa,KAAA,GAAW,KAAK,WAAW;EAC5C,IAAI,aAAa,KAAA,GAAW,KAAK,WAAW;EAC5C,IAAI,eAAe,KAAA,GAAW,KAAK,aAAa;EAChD,KAAK,iBAAiB,kBAAkB,MAAM,OAAO;CACvD;CAEA,IAAc,SAIL;EACP,MAAM,QAAQ,YAAY,IAAI;EAC9B,MAAM,EAAE,YAAY,MAAM,aAAa;EACvC,MAAM,UAAU,cAAsB;GACpC,MAAM,UAAU,YAAY;GAC5B,MAAM,WAAW,KAAK,IAAI,UAAU,UAAU,CAAC;GAC/C,KAAK,QAAQ;GACb,IAAI,WAAW,GACb,KAAK,UAAU,sBAAsB,MAAM;QACtC;IACL,KAAK,QAAQ;IACb,KAAK,KAAK;IACV,WAAW;GACb;EACF;EACA,KAAK,UAAU,sBAAsB,MAAM;CAC7C;CAEA,GAAG,OAAuB,QAAmC;EAC3D,KAAK,KAAK;EACV,MAAM,YAAY,EAAE,GAAG,KAAK,MAAM;EAClC,MAAM,gBAAgB,OAAO,KAAK,KAAK,CAAC,CAAC,QACtC,KAAK,QAAQ;GACZ,MAAM,IAAI;GACV,MAAM,KAAK,MAAM;GACjB,MAAM,KAAK,UAAU;GACrB,KAAK,MAAM,OAAO,OAAO,MAAM,OAAO,IACpC,IAAI,OAAO,KAAK;GAElB,OAAO;EACT,GACA,CAAC,CACH;EACA,MAAM,WAAW,QAAQ,YAAY,KAAK;EAC1C,MAAM,iBAAiB,QAAQ,kBAAkB,KAAK;EACtD,MAAM,aAAa,QAAQ,cAAc,KAAK;EAC9C,KAAK,IAAI;GACP;GACA,kBAAkB;IAChB,KAAK,MAAM,OAAO,OAChB,IAAI,OAAO,KAAK,OACb,KAAM,MAAiC,OAAO,MAAM;IAGzD,KAAK,WAAW,KAAK,KAAK;IAC1B,aAAa,KAAK,KAAK;GACzB;GACA,OAAO,aAAa;IAClB,KAAK,MAAM,OAAO,eAAe;KAE/B,MAAM,SAAU,UAAUA,OAAiB,eAAe,QAAQ,IAAI,cAAc;KACnF,KAAM,MAAiC,OAAO;IACjD;IACA,KAAK,WAAW,KAAK,KAAK;GAC5B;EACF,CAAC;CACH;CAEA,KAAK,QAAmC;EACtC,KAAK,GAAG,KAAK,cAAgC,MAAM;CACrD;CAEA,OAAwB;EACtB,IAAI,CAAC,KAAK,SAAS,OAAO;EAC1B,qBAAqB,KAAK,OAAO;EACjC,KAAK,UAAU,KAAA;EACf,OAAO;CACT;CAEA,OAAgB,SAAS;EACvB,SAAS,MAAc;EACvB,aAAa,MAAc,IAAI;EAC/B,cAAc,MAAc,KAAK,IAAI;EACrC,gBAAgB,MAAe,IAAI,KAAM,IAAI,IAAI,IAAI,MAAM,IAAI,IAAI,KAAK;EACxE,aAAa,MAAc,IAAI,KAAM,UAAe,IAAI;EACxD,cAAc,MAAc,IAAI,UAAU,KAAK,IAAI,IAAI,GAAG,CAAC,IAAI,UAAU,KAAK,IAAI,IAAI,GAAG,CAAC;EAC1F,gBAAgB,MAAc;GAE5B,MAAM,KAAK;GACX,OAAO,IAAI,KACN,KAAK,IAAI,IAAI,GAAG,CAAC,KAAK,WAAe,IAAI,MAAO,KAChD,KAAK,IAAI,IAAI,IAAI,GAAG,CAAC,KAAM,aAAW,IAAI,IAAI,KAAK,MAAM,KAAK;EACrE;CACF;AACF"}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,10 +10,11 @@ import { createIncrease } from "./helper/create-increase.js";
|
|
|
10
10
|
import { createToggle } from "./helper/create-toggle.js";
|
|
11
11
|
import { AnimeConfig, Tween, TweenConfig } from "./helper/tween.js";
|
|
12
12
|
import { nextFrame } from "./helper/frame.js";
|
|
13
|
+
import { OverflowNavState, OverflowNavViewport, applyWheelHorizontalScroll, computeOverflowNavState, scrollElementIntoView, scrollViewportByStep } from "./helper/overflow-nav.js";
|
|
13
14
|
import { extractNormalVNodes, fieldKey, isComment, isFragment, isTemplate, isTextNode, shallowComputed } from "./helper/vue.js";
|
|
14
15
|
import { middleProxy } from "./reactive/proxy.js";
|
|
15
16
|
import { DeconstructValue, DefineEvent, Index, Null, RenderReturn, Undef } from "./types/helper.js";
|
|
16
17
|
import { BreakpointName, ColorType, ComponentProps, ComponentSize, FormComponentProps, PresetRule, PropsWithServerQuery, ValidateRule } from "./types/component-common.js";
|
|
17
18
|
import { FormContextModel, FormContextProps, FormFieldItem, injectFormContext, provideFormContext } from "./types/form-context.js";
|
|
18
19
|
import "./types/index.js";
|
|
19
|
-
export { AnimeConfig, BEM, BEMFactory, BreakpointName, CLS_PREFIX, ColorType, ComponentProps, ComponentSize, DeconstructValue, DefineEvent, ExpandTransition, ExpandTransitionOptions, FORM_EMPTY_CONTENT, FormComponentProps, FormContextModel, FormContextProps, FormFieldItem, Index, NAME_SPACE, Null, PresetRule, PropsWithServerQuery, RenderReturn, Tween, TweenConfig, Undef, ValidateRule, addClass, bem, createIncrease, createToggle, extractNormalVNodes, fieldKey, getHighlightChunks, getNearestScrollParent, getScrollParents, injectFormContext, isComment, isFragment, isTemplate, isTextNode, makeBEM, middleProxy, nextFrame, provideFormContext, removeClass, removeStyles, scrollIntoContainerView, setStyles, shallowComputed, withUnit, zIndex };
|
|
20
|
+
export { AnimeConfig, BEM, BEMFactory, BreakpointName, CLS_PREFIX, ColorType, ComponentProps, ComponentSize, DeconstructValue, DefineEvent, ExpandTransition, ExpandTransitionOptions, FORM_EMPTY_CONTENT, FormComponentProps, FormContextModel, FormContextProps, FormFieldItem, Index, NAME_SPACE, Null, OverflowNavState, OverflowNavViewport, PresetRule, PropsWithServerQuery, RenderReturn, Tween, TweenConfig, Undef, ValidateRule, addClass, applyWheelHorizontalScroll, bem, computeOverflowNavState, createIncrease, createToggle, extractNormalVNodes, fieldKey, getHighlightChunks, getNearestScrollParent, getScrollParents, injectFormContext, isComment, isFragment, isTemplate, isTextNode, makeBEM, middleProxy, nextFrame, provideFormContext, removeClass, removeStyles, scrollElementIntoView, scrollIntoContainerView, scrollViewportByStep, setStyles, shallowComputed, withUnit, zIndex };
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,8 @@ import { zIndex } from "./dom/z-index.js";
|
|
|
10
10
|
import { createToggle } from "./helper/create-toggle.js";
|
|
11
11
|
import { Tween } from "./helper/tween.js";
|
|
12
12
|
import { nextFrame } from "./helper/frame.js";
|
|
13
|
+
import { applyWheelHorizontalScroll, computeOverflowNavState, scrollElementIntoView, scrollViewportByStep } from "./helper/overflow-nav.js";
|
|
13
14
|
import { extractNormalVNodes, fieldKey, isComment, isFragment, isTemplate, isTextNode, shallowComputed } from "./helper/vue.js";
|
|
14
15
|
import { middleProxy } from "./reactive/proxy.js";
|
|
15
16
|
import { injectFormContext, provideFormContext } from "./types/form-context.js";
|
|
16
|
-
export { CLS_PREFIX, ExpandTransition, FORM_EMPTY_CONTENT, NAME_SPACE, Tween, addClass, bem, createIncrease, createToggle, extractNormalVNodes, fieldKey, getHighlightChunks, getNearestScrollParent, getScrollParents, injectFormContext, isComment, isFragment, isTemplate, isTextNode, makeBEM, middleProxy, nextFrame, provideFormContext, removeClass, removeStyles, scrollIntoContainerView, setStyles, shallowComputed, withUnit, zIndex };
|
|
17
|
+
export { CLS_PREFIX, ExpandTransition, FORM_EMPTY_CONTENT, NAME_SPACE, Tween, addClass, applyWheelHorizontalScroll, bem, computeOverflowNavState, createIncrease, createToggle, extractNormalVNodes, fieldKey, getHighlightChunks, getNearestScrollParent, getScrollParents, injectFormContext, isComment, isFragment, isTemplate, isTextNode, makeBEM, middleProxy, nextFrame, provideFormContext, removeClass, removeStyles, scrollElementIntoView, scrollIntoContainerView, scrollViewportByStep, setStyles, shallowComputed, withUnit, zIndex };
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
2
|
|
|
3
3
|
import { ExpandTransition } from '../expand-transition'
|
|
4
4
|
|
|
@@ -96,4 +96,54 @@ describe('ExpandTransition', () => {
|
|
|
96
96
|
transition.cancel(el)
|
|
97
97
|
}
|
|
98
98
|
})
|
|
99
|
+
|
|
100
|
+
it('invokes onEnd after the transition settles', () => {
|
|
101
|
+
const el = createPanel()
|
|
102
|
+
const transition = new ExpandTransition({ transition: 'height 0.25s ease' })
|
|
103
|
+
const onEnd = vi.fn()
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
transition.collapse(el, onEnd)
|
|
107
|
+
expect(onEnd).not.toHaveBeenCalled()
|
|
108
|
+
|
|
109
|
+
fireHeightTransitionEnd(el)
|
|
110
|
+
expect(onEnd).toHaveBeenCalledTimes(1)
|
|
111
|
+
} finally {
|
|
112
|
+
el.remove()
|
|
113
|
+
transition.cancel(el)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('does not invoke onEnd when interrupted by a new animation', () => {
|
|
118
|
+
const el = createPanel()
|
|
119
|
+
const transition = new ExpandTransition({ transition: 'height 0.25s ease' })
|
|
120
|
+
const onEnd = vi.fn()
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
transition.collapse(el, onEnd)
|
|
124
|
+
// 新动画打断旧动画:旧 onEnd 不应再触发
|
|
125
|
+
transition.expand(el)
|
|
126
|
+
fireHeightTransitionEnd(el)
|
|
127
|
+
expect(onEnd).not.toHaveBeenCalled()
|
|
128
|
+
} finally {
|
|
129
|
+
el.remove()
|
|
130
|
+
transition.cancel(el)
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('invokes onEnd immediately when no animation is needed', () => {
|
|
135
|
+
const el = document.createElement('div')
|
|
136
|
+
document.body.appendChild(el)
|
|
137
|
+
const transition = new ExpandTransition({ transition: 'height 0.25s ease' })
|
|
138
|
+
const onEnd = vi.fn()
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
// 高度已为 0(happy-dom 无布局),收起无需动画,onEnd 同步调用
|
|
142
|
+
transition.collapse(el, onEnd)
|
|
143
|
+
expect(onEnd).toHaveBeenCalledTimes(1)
|
|
144
|
+
} finally {
|
|
145
|
+
el.remove()
|
|
146
|
+
transition.cancel(el)
|
|
147
|
+
}
|
|
148
|
+
})
|
|
99
149
|
})
|
|
@@ -93,12 +93,17 @@ export class ExpandTransition {
|
|
|
93
93
|
this.resetTransitionStyles(el)
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
/**
|
|
97
|
+
* 展开动画;`onEnd` 在动画落定(含无需动画直接落定)后调用,
|
|
98
|
+
* 被新的动画打断时不调用
|
|
99
|
+
*/
|
|
100
|
+
expand(el: HTMLElement, onEnd?: () => void) {
|
|
101
|
+
this.animate(el, true, onEnd)
|
|
98
102
|
}
|
|
99
103
|
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
/** 收起动画;`onEnd` 语义同 `expand` */
|
|
105
|
+
collapse(el: HTMLElement, onEnd?: () => void) {
|
|
106
|
+
this.animate(el, false, onEnd)
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
setExpanded(el: HTMLElement, expanded: boolean) {
|
|
@@ -108,7 +113,7 @@ export class ExpandTransition {
|
|
|
108
113
|
el.style.height = expanded ? 'auto' : '0px'
|
|
109
114
|
}
|
|
110
115
|
|
|
111
|
-
private animate(el: HTMLElement, expanded: boolean) {
|
|
116
|
+
private animate(el: HTMLElement, expanded: boolean, onEnd?: () => void) {
|
|
112
117
|
this.cancel(el)
|
|
113
118
|
|
|
114
119
|
const startHeight = el.offsetHeight
|
|
@@ -117,6 +122,7 @@ export class ExpandTransition {
|
|
|
117
122
|
|
|
118
123
|
if (startHeight === endHeight) {
|
|
119
124
|
this.setExpanded(el, expanded)
|
|
125
|
+
onEnd?.()
|
|
120
126
|
return
|
|
121
127
|
}
|
|
122
128
|
|
|
@@ -139,21 +145,22 @@ export class ExpandTransition {
|
|
|
139
145
|
})
|
|
140
146
|
|
|
141
147
|
const cleanup = () => {
|
|
142
|
-
el.removeEventListener('transitionend',
|
|
143
|
-
el.removeEventListener('transitioncancel',
|
|
148
|
+
el.removeEventListener('transitionend', onEndHandler)
|
|
149
|
+
el.removeEventListener('transitioncancel', onEndHandler)
|
|
144
150
|
this.cleanupMap.delete(el)
|
|
145
151
|
}
|
|
146
152
|
|
|
147
|
-
const
|
|
153
|
+
const onEndHandler = (e: TransitionEvent) => {
|
|
148
154
|
if (e.target !== el || e.propertyName !== 'height') return
|
|
149
155
|
cleanup()
|
|
150
156
|
this.resetTemporaryStyles(el)
|
|
151
157
|
el.style.overflow = 'hidden'
|
|
152
158
|
el.style.height = expanded ? 'auto' : '0px'
|
|
159
|
+
onEnd?.()
|
|
153
160
|
}
|
|
154
161
|
|
|
155
|
-
el.addEventListener('transitionend',
|
|
156
|
-
el.addEventListener('transitioncancel',
|
|
162
|
+
el.addEventListener('transitionend', onEndHandler)
|
|
163
|
+
el.addEventListener('transitioncancel', onEndHandler)
|
|
157
164
|
this.cleanupMap.set(el, cleanup)
|
|
158
165
|
}
|
|
159
166
|
|
package/src/dom/position.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 获取可滚动的父级(纵向或横向可滚动均计入:overflow-x 容器如横向滚动条
|
|
3
|
+
* 也是滚动父级,弹层需监听其 scroll 事件)
|
|
3
4
|
* @param el 元素
|
|
4
5
|
* @returns
|
|
5
6
|
*/
|
|
@@ -7,7 +8,7 @@ export function getScrollParents(el: HTMLElement): HTMLElement[] {
|
|
|
7
8
|
const parents: HTMLElement[] = []
|
|
8
9
|
let parent = el.parentElement
|
|
9
10
|
while (parent) {
|
|
10
|
-
if (parent.scrollHeight > parent.clientHeight) {
|
|
11
|
+
if (parent.scrollHeight > parent.clientHeight || parent.scrollWidth > parent.clientWidth) {
|
|
11
12
|
parents.push(parent)
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -17,14 +18,14 @@ export function getScrollParents(el: HTMLElement): HTMLElement[] {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
*
|
|
21
|
+
* 获取最近的可滚动父级(纵向或横向可滚动均计入)
|
|
21
22
|
* @param el 元素
|
|
22
23
|
* @returns 最近的可滚动父级
|
|
23
24
|
*/
|
|
24
25
|
export function getNearestScrollParent(el: HTMLElement): HTMLElement | null {
|
|
25
26
|
let parent = el.parentElement
|
|
26
27
|
while (parent) {
|
|
27
|
-
if (parent.scrollHeight > parent.clientHeight) {
|
|
28
|
+
if (parent.scrollHeight > parent.clientHeight || parent.scrollWidth > parent.clientWidth) {
|
|
28
29
|
return parent
|
|
29
30
|
}
|
|
30
31
|
parent = parent.parentElement
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 水平溢出滚动导航的共享纯逻辑(tabs / toolbar 三处复制的收敛,见 #17)。
|
|
3
|
+
* 只提供无 Vue 依赖的计算与副作用,事件绑定 / ResizeObserver / watch 由各
|
|
4
|
+
* composable 按需组合(三处的监听源与生命周期有真实差异,不强行统一)。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** 视口滚动几何(scrollTo 兼容 HTMLElement 与 vue 模板 ref) */
|
|
8
|
+
export interface OverflowNavViewport {
|
|
9
|
+
scrollLeft: number
|
|
10
|
+
scrollWidth: number
|
|
11
|
+
clientWidth: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 导航按钮状态 */
|
|
15
|
+
export interface OverflowNavState {
|
|
16
|
+
/** 内容是否溢出视口 */
|
|
17
|
+
overflowing: boolean
|
|
18
|
+
/** 可向前(左/上)滚动 */
|
|
19
|
+
canPrev: boolean
|
|
20
|
+
/** 可向后(右/下)滚动 */
|
|
21
|
+
canNext: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 依据视口与内容尺寸推导滚动按钮可用状态 */
|
|
25
|
+
export function computeOverflowNavState(vp: OverflowNavViewport): OverflowNavState {
|
|
26
|
+
const overflowing = vp.scrollWidth - vp.clientWidth > 1
|
|
27
|
+
return {
|
|
28
|
+
overflowing,
|
|
29
|
+
canPrev: overflowing && vp.scrollLeft > 0,
|
|
30
|
+
canNext: overflowing && vp.scrollLeft + vp.clientWidth < vp.scrollWidth - 1
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type ScrollableElement = OverflowNavViewport & {
|
|
35
|
+
scrollTo(options: { left: number; behavior: 'smooth' }): void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 箭头按钮:按视口宽度 80% 的步长平滑滚动 */
|
|
39
|
+
export function scrollViewportByStep(vp: ScrollableElement, dir: 1 | -1): void {
|
|
40
|
+
vp.scrollTo({ left: vp.scrollLeft + dir * vp.clientWidth * 0.8, behavior: 'smooth' })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 目标元素滚入视口(左/右越界时对齐到边缘,留 offset 边距) */
|
|
44
|
+
export function scrollElementIntoView(vp: HTMLElement, el: HTMLElement, offset = 8): void {
|
|
45
|
+
const vpRect = vp.getBoundingClientRect()
|
|
46
|
+
const elRect = el.getBoundingClientRect()
|
|
47
|
+
if (elRect.left < vpRect.left) {
|
|
48
|
+
vp.scrollTo({ left: vp.scrollLeft + (elRect.left - vpRect.left) - offset, behavior: 'smooth' })
|
|
49
|
+
} else if (elRect.right > vpRect.right) {
|
|
50
|
+
vp.scrollTo({
|
|
51
|
+
left: vp.scrollLeft + (elRect.right - vpRect.right) + offset,
|
|
52
|
+
behavior: 'smooth'
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 鼠标滚轮(纵向)驱动水平滚动;触控板横滑(已有 deltaX)不拦截。
|
|
59
|
+
* navActive = 导航按钮是否可见(内容未溢出时不消费滚轮)。
|
|
60
|
+
*/
|
|
61
|
+
export function applyWheelHorizontalScroll(
|
|
62
|
+
e: WheelEvent,
|
|
63
|
+
vp: HTMLElement,
|
|
64
|
+
navActive: boolean
|
|
65
|
+
): void {
|
|
66
|
+
if (!navActive) return
|
|
67
|
+
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return
|
|
68
|
+
if (e.deltaY === 0) return
|
|
69
|
+
e.preventDefault()
|
|
70
|
+
vp.scrollLeft += e.deltaY
|
|
71
|
+
}
|