@veltra/utils 1.1.0 → 1.1.2

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.
@@ -0,0 +1,27 @@
1
+ //#region src/dom/expand-transition.d.ts
2
+ interface ExpandTransitionOptions {
3
+ transition?: string;
4
+ enterTransition?: string;
5
+ leaveTransition?: string;
6
+ opacity?: boolean;
7
+ }
8
+ declare class ExpandTransition {
9
+ private options;
10
+ private cleanupMap;
11
+ constructor(options?: ExpandTransitionOptions);
12
+ cancel(el: HTMLElement): void;
13
+ enter(el: HTMLElement): void;
14
+ afterEnter(el: HTMLElement): void;
15
+ beforeLeave(el: HTMLElement): void;
16
+ leave(el: HTMLElement): void;
17
+ afterLeave(el: HTMLElement): void;
18
+ expand(el: HTMLElement): void;
19
+ collapse(el: HTMLElement): void;
20
+ setExpanded(el: HTMLElement, expanded: boolean): void;
21
+ private animate;
22
+ private resetTransitionStyles;
23
+ private resetTemporaryStyles;
24
+ }
25
+ //#endregion
26
+ export { ExpandTransition, ExpandTransitionOptions };
27
+ //# sourceMappingURL=expand-transition.d.ts.map
@@ -0,0 +1,149 @@
1
+ import { removeStyles, setStyles } from "./style.js";
2
+ //#region src/dom/expand-transition.ts
3
+ function readVerticalPadding(el) {
4
+ const { paddingTop, paddingBottom } = getComputedStyle(el);
5
+ return {
6
+ paddingTop,
7
+ paddingBottom
8
+ };
9
+ }
10
+ function getTransition(options, type) {
11
+ return (type === "enter" ? options.enterTransition : options.leaveTransition) ?? options.transition ?? "height 0.25s cubic-bezier(0.4, 0, 0.2, 1)";
12
+ }
13
+ var ExpandTransition = class {
14
+ cleanupMap = /* @__PURE__ */ new Map();
15
+ constructor(options = {}) {
16
+ this.options = options;
17
+ }
18
+ cancel(el) {
19
+ this.cleanupMap.get(el)?.();
20
+ this.cleanupMap.delete(el);
21
+ }
22
+ enter(el) {
23
+ const height = el.scrollHeight;
24
+ const { paddingTop, paddingBottom } = readVerticalPadding(el);
25
+ setStyles(el, {
26
+ boxSizing: "border-box",
27
+ height: 0,
28
+ paddingTop: 0,
29
+ paddingBottom: 0,
30
+ overflow: "hidden",
31
+ transition: getTransition(this.options, "enter"),
32
+ willChange: this.options.opacity ? "height, opacity" : "height"
33
+ });
34
+ if (this.options.opacity) el.style.opacity = "0";
35
+ el.offsetHeight;
36
+ setStyles(el, {
37
+ height: `${height}px`,
38
+ paddingTop,
39
+ paddingBottom
40
+ });
41
+ if (this.options.opacity) el.style.opacity = "1";
42
+ }
43
+ afterEnter(el) {
44
+ this.resetTransitionStyles(el);
45
+ }
46
+ beforeLeave(el) {
47
+ const { paddingTop, paddingBottom } = readVerticalPadding(el);
48
+ setStyles(el, {
49
+ boxSizing: "border-box",
50
+ height: `${el.scrollHeight}px`,
51
+ paddingTop,
52
+ paddingBottom,
53
+ overflow: "hidden",
54
+ transition: getTransition(this.options, "leave"),
55
+ willChange: this.options.opacity ? "height, opacity" : "height"
56
+ });
57
+ if (this.options.opacity) el.style.opacity = "1";
58
+ }
59
+ leave(el) {
60
+ el.offsetHeight;
61
+ setStyles(el, {
62
+ height: 0,
63
+ paddingTop: 0,
64
+ paddingBottom: 0
65
+ });
66
+ if (this.options.opacity) el.style.opacity = "0";
67
+ }
68
+ afterLeave(el) {
69
+ this.resetTransitionStyles(el);
70
+ }
71
+ expand(el) {
72
+ this.animate(el, true);
73
+ }
74
+ collapse(el) {
75
+ this.animate(el, false);
76
+ }
77
+ setExpanded(el, expanded) {
78
+ this.cancel(el);
79
+ this.resetTemporaryStyles(el);
80
+ el.style.overflow = "hidden";
81
+ el.style.height = expanded ? "auto" : "0px";
82
+ }
83
+ animate(el, expanded) {
84
+ this.cancel(el);
85
+ const startHeight = el.offsetHeight;
86
+ const endHeight = expanded ? el.scrollHeight : 0;
87
+ const { paddingTop, paddingBottom } = readVerticalPadding(el);
88
+ if (startHeight === endHeight) {
89
+ this.setExpanded(el, expanded);
90
+ return;
91
+ }
92
+ setStyles(el, {
93
+ boxSizing: "border-box",
94
+ height: `${startHeight}px`,
95
+ paddingTop,
96
+ paddingBottom,
97
+ overflow: "hidden",
98
+ transition: getTransition(this.options, expanded ? "enter" : "leave"),
99
+ willChange: "height"
100
+ });
101
+ el.offsetHeight;
102
+ setStyles(el, {
103
+ height: `${endHeight}px`,
104
+ paddingTop: expanded ? paddingTop : 0,
105
+ paddingBottom: expanded ? paddingBottom : 0
106
+ });
107
+ const cleanup = () => {
108
+ el.removeEventListener("transitionend", onEnd);
109
+ el.removeEventListener("transitioncancel", onEnd);
110
+ this.cleanupMap.delete(el);
111
+ };
112
+ const onEnd = (e) => {
113
+ if (e.target !== el || e.propertyName !== "height") return;
114
+ cleanup();
115
+ this.resetTemporaryStyles(el);
116
+ el.style.overflow = "hidden";
117
+ el.style.height = expanded ? "auto" : "0px";
118
+ };
119
+ el.addEventListener("transitionend", onEnd);
120
+ el.addEventListener("transitioncancel", onEnd);
121
+ this.cleanupMap.set(el, cleanup);
122
+ }
123
+ resetTransitionStyles(el) {
124
+ removeStyles(el, [
125
+ "box-sizing",
126
+ "height",
127
+ "padding-top",
128
+ "padding-bottom",
129
+ "overflow",
130
+ "transition",
131
+ "opacity",
132
+ "will-change"
133
+ ]);
134
+ }
135
+ resetTemporaryStyles(el) {
136
+ removeStyles(el, [
137
+ "box-sizing",
138
+ "padding-top",
139
+ "padding-bottom",
140
+ "transition",
141
+ "opacity",
142
+ "will-change"
143
+ ]);
144
+ }
145
+ };
146
+ //#endregion
147
+ export { ExpandTransition };
148
+
149
+ //# sourceMappingURL=expand-transition.js.map
@@ -0,0 +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', onEnd)\n el.removeEventListener('transitioncancel', onEnd)\n this.cleanupMap.delete(el)\n }\n\n const onEnd = (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 }\n\n el.addEventListener('transitionend', onEnd)\n el.addEventListener('transitioncancel', onEnd)\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/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { BEM, BEMFactory, makeBEM } from "./helper/make-bem.js";
2
2
  import { CLS_PREFIX, FORM_EMPTY_CONTENT, NAME_SPACE } from "./shared/constants.js";
3
3
  import { addClass, bem, removeClass } from "./dom/class-name.js";
4
+ import { ExpandTransition, ExpandTransitionOptions } from "./dom/expand-transition.js";
4
5
  import { getHighlightChunks } from "./dom/highlight.js";
5
6
  import { getNearestScrollParent, getScrollParents, scrollIntoContainerView } from "./dom/position.js";
6
7
  import { removeStyles, setStyles, withUnit } from "./dom/style.js";
@@ -16,4 +17,4 @@ import { middleProxy } from "./reactive/proxy.js";
16
17
  import { DeconstructValue, DefineEvent, Index, Null, RenderReturn, Undef } from "./types/helper.js";
17
18
  import { BreakpointName, ColorType, ComponentProps, ComponentSize, FormComponentProps, PropsWithServerQuery } from "./types/component-common.js";
18
19
  import { FormContextInjectProps } from "./types/form-context.js";
19
- export { AnimeConfig, BEM, BEMFactory, BreakpointName, CLS_PREFIX, ColorType, ComponentProps, ComponentSize, Data, DeconstructValue, DefineEvent, FORM_EMPTY_CONTENT, FormComponentProps, FormContextInjectProps, Index, NAME_SPACE, Null, PresetRule, PropsWithServerQuery, RenderReturn, Tween, TweenConfig, Undef, ValidateRule, Validator, ValidatorConfig, addClass, bem, createIncrease, createToggle, extractNormalVNodes, getHighlightChunks, getNearestScrollParent, getScrollParents, isComment, isFragment, isTemplate, isTextNode, makeBEM, middleProxy, nextFrame, removeClass, removeStyles, scrollIntoContainerView, setStyles, shallowComputed, withUnit, zIndex };
20
+ export { AnimeConfig, BEM, BEMFactory, BreakpointName, CLS_PREFIX, ColorType, ComponentProps, ComponentSize, Data, DeconstructValue, DefineEvent, ExpandTransition, ExpandTransitionOptions, FORM_EMPTY_CONTENT, FormComponentProps, FormContextInjectProps, Index, NAME_SPACE, Null, PresetRule, PropsWithServerQuery, RenderReturn, Tween, TweenConfig, Undef, ValidateRule, Validator, ValidatorConfig, addClass, bem, createIncrease, createToggle, extractNormalVNodes, getHighlightChunks, getNearestScrollParent, getScrollParents, isComment, isFragment, isTemplate, isTextNode, makeBEM, middleProxy, nextFrame, removeClass, removeStyles, scrollIntoContainerView, setStyles, shallowComputed, withUnit, zIndex };
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { makeBEM } from "./helper/make-bem.js";
2
2
  import { CLS_PREFIX, FORM_EMPTY_CONTENT, NAME_SPACE } from "./shared/constants.js";
3
3
  import { addClass, bem, removeClass } from "./dom/class-name.js";
4
+ import { removeStyles, setStyles, withUnit } from "./dom/style.js";
5
+ import { ExpandTransition } from "./dom/expand-transition.js";
4
6
  import { getHighlightChunks } from "./dom/highlight.js";
5
7
  import { getNearestScrollParent, getScrollParents, scrollIntoContainerView } from "./dom/position.js";
6
- import { removeStyles, setStyles, withUnit } from "./dom/style.js";
7
8
  import { createIncrease } from "./helper/create-increase.js";
8
9
  import { zIndex } from "./dom/z-index.js";
9
10
  import { Validator } from "./form/validate.js";
@@ -12,4 +13,4 @@ import { Tween } from "./helper/tween.js";
12
13
  import { nextFrame } from "./helper/frame.js";
13
14
  import { extractNormalVNodes, isComment, isFragment, isTemplate, isTextNode, shallowComputed } from "./helper/vue.js";
14
15
  import { middleProxy } from "./reactive/proxy.js";
15
- export { CLS_PREFIX, FORM_EMPTY_CONTENT, NAME_SPACE, Tween, Validator, addClass, bem, createIncrease, createToggle, extractNormalVNodes, getHighlightChunks, getNearestScrollParent, getScrollParents, isComment, isFragment, isTemplate, isTextNode, makeBEM, middleProxy, nextFrame, removeClass, removeStyles, scrollIntoContainerView, setStyles, shallowComputed, withUnit, zIndex };
16
+ export { CLS_PREFIX, ExpandTransition, FORM_EMPTY_CONTENT, NAME_SPACE, Tween, Validator, addClass, bem, createIncrease, createToggle, extractNormalVNodes, getHighlightChunks, getNearestScrollParent, getScrollParents, isComment, isFragment, isTemplate, isTextNode, makeBEM, middleProxy, nextFrame, removeClass, removeStyles, scrollIntoContainerView, setStyles, shallowComputed, withUnit, zIndex };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veltra/utils",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "files": [
5
5
  "dist",
6
6
  "src"
@@ -0,0 +1,98 @@
1
+ // @vitest-environment happy-dom
2
+ import { ExpandTransition } from '../expand-transition'
3
+
4
+ function createPanel() {
5
+ const el = document.createElement('div')
6
+
7
+ el.style.paddingTop = '2px'
8
+ el.style.paddingBottom = '2px'
9
+ document.body.appendChild(el)
10
+
11
+ Object.defineProperties(el, {
12
+ offsetHeight: { configurable: true, value: 16 },
13
+ scrollHeight: { configurable: true, value: 44 }
14
+ })
15
+
16
+ return el
17
+ }
18
+
19
+ function fireHeightTransitionEnd(el: HTMLElement) {
20
+ const event = new Event('transitionend') as TransitionEvent
21
+ Object.defineProperty(event, 'propertyName', { configurable: true, value: 'height' })
22
+ el.dispatchEvent(event)
23
+ }
24
+
25
+ describe('ExpandTransition', () => {
26
+ it('drives Vue transition hooks with a measured border-box height', () => {
27
+ const el = createPanel()
28
+ const transition = new ExpandTransition({
29
+ enterTransition: 'height 0.25s ease, padding-top 0.25s ease, padding-bottom 0.25s ease',
30
+ leaveTransition: 'height 0.2s ease, padding-top 0.2s ease, padding-bottom 0.2s ease',
31
+ opacity: true
32
+ })
33
+
34
+ try {
35
+ transition.enter(el)
36
+
37
+ expect(el.style.height).toBe('44px')
38
+ expect(el.style.paddingTop).toBe('2px')
39
+ expect(el.style.paddingBottom).toBe('2px')
40
+ expect(el.style.boxSizing).toBe('border-box')
41
+ expect(el.style.opacity).toBe('1')
42
+
43
+ transition.beforeLeave(el)
44
+ transition.leave(el)
45
+
46
+ expect(el.style.height).toBe('0px')
47
+ expect(el.style.paddingTop).toBe('0px')
48
+ expect(el.style.paddingBottom).toBe('0px')
49
+ expect(el.style.boxSizing).toBe('border-box')
50
+ expect(el.style.opacity).toBe('0')
51
+ } finally {
52
+ el.remove()
53
+ }
54
+ })
55
+
56
+ it('keeps expanded imperative panels at auto height after transition end', () => {
57
+ const el = createPanel()
58
+ const transition = new ExpandTransition({ transition: 'height 0.25s ease' })
59
+
60
+ try {
61
+ transition.expand(el)
62
+
63
+ expect(el.style.height).toBe('44px')
64
+ expect(el.style.overflow).toBe('hidden')
65
+
66
+ fireHeightTransitionEnd(el)
67
+
68
+ expect(el.style.height).toBe('auto')
69
+ expect(el.style.transition).toBe('')
70
+ expect(el.style.willChange).toBe('')
71
+ } finally {
72
+ el.remove()
73
+ transition.cancel(el)
74
+ }
75
+ })
76
+
77
+ it('keeps collapsed imperative panels at zero height after transition end', () => {
78
+ const el = createPanel()
79
+ const transition = new ExpandTransition({ transition: 'height 0.25s ease' })
80
+
81
+ try {
82
+ transition.collapse(el)
83
+
84
+ expect(el.style.height).toBe('0px')
85
+ expect(el.style.paddingTop).toBe('0px')
86
+ expect(el.style.paddingBottom).toBe('0px')
87
+
88
+ fireHeightTransitionEnd(el)
89
+
90
+ expect(el.style.height).toBe('0px')
91
+ expect(el.style.transition).toBe('')
92
+ expect(el.style.willChange).toBe('')
93
+ } finally {
94
+ el.remove()
95
+ transition.cancel(el)
96
+ }
97
+ })
98
+ })
@@ -0,0 +1,183 @@
1
+ import { removeStyles, setStyles } from './style'
2
+
3
+ export interface ExpandTransitionOptions {
4
+ transition?: string
5
+ enterTransition?: string
6
+ leaveTransition?: string
7
+ opacity?: boolean
8
+ }
9
+
10
+ function readVerticalPadding(el: HTMLElement) {
11
+ const { paddingTop, paddingBottom } = getComputedStyle(el)
12
+ return { paddingTop, paddingBottom }
13
+ }
14
+
15
+ function getTransition(options: ExpandTransitionOptions, type: 'enter' | 'leave') {
16
+ return (
17
+ (type === 'enter' ? options.enterTransition : options.leaveTransition) ??
18
+ options.transition ??
19
+ 'height 0.25s cubic-bezier(0.4, 0, 0.2, 1)'
20
+ )
21
+ }
22
+
23
+ export class ExpandTransition {
24
+ private cleanupMap = new Map<HTMLElement, () => void>()
25
+
26
+ constructor(private options: ExpandTransitionOptions = {}) {}
27
+
28
+ cancel(el: HTMLElement) {
29
+ this.cleanupMap.get(el)?.()
30
+ this.cleanupMap.delete(el)
31
+ }
32
+
33
+ enter(el: HTMLElement) {
34
+ const height = el.scrollHeight
35
+ const { paddingTop, paddingBottom } = readVerticalPadding(el)
36
+
37
+ setStyles(el, {
38
+ boxSizing: 'border-box',
39
+ height: 0,
40
+ paddingTop: 0,
41
+ paddingBottom: 0,
42
+ overflow: 'hidden',
43
+ transition: getTransition(this.options, 'enter'),
44
+ willChange: this.options.opacity ? 'height, opacity' : 'height'
45
+ })
46
+
47
+ if (this.options.opacity) {
48
+ el.style.opacity = '0'
49
+ }
50
+
51
+ void el.offsetHeight
52
+
53
+ setStyles(el, { height: `${height}px`, paddingTop, paddingBottom })
54
+
55
+ if (this.options.opacity) {
56
+ el.style.opacity = '1'
57
+ }
58
+ }
59
+
60
+ afterEnter(el: HTMLElement) {
61
+ this.resetTransitionStyles(el)
62
+ }
63
+
64
+ beforeLeave(el: HTMLElement) {
65
+ const { paddingTop, paddingBottom } = readVerticalPadding(el)
66
+
67
+ setStyles(el, {
68
+ boxSizing: 'border-box',
69
+ height: `${el.scrollHeight}px`,
70
+ paddingTop,
71
+ paddingBottom,
72
+ overflow: 'hidden',
73
+ transition: getTransition(this.options, 'leave'),
74
+ willChange: this.options.opacity ? 'height, opacity' : 'height'
75
+ })
76
+
77
+ if (this.options.opacity) {
78
+ el.style.opacity = '1'
79
+ }
80
+ }
81
+
82
+ leave(el: HTMLElement) {
83
+ void el.offsetHeight
84
+
85
+ setStyles(el, { height: 0, paddingTop: 0, paddingBottom: 0 })
86
+
87
+ if (this.options.opacity) {
88
+ el.style.opacity = '0'
89
+ }
90
+ }
91
+
92
+ afterLeave(el: HTMLElement) {
93
+ this.resetTransitionStyles(el)
94
+ }
95
+
96
+ expand(el: HTMLElement) {
97
+ this.animate(el, true)
98
+ }
99
+
100
+ collapse(el: HTMLElement) {
101
+ this.animate(el, false)
102
+ }
103
+
104
+ setExpanded(el: HTMLElement, expanded: boolean) {
105
+ this.cancel(el)
106
+ this.resetTemporaryStyles(el)
107
+ el.style.overflow = 'hidden'
108
+ el.style.height = expanded ? 'auto' : '0px'
109
+ }
110
+
111
+ private animate(el: HTMLElement, expanded: boolean) {
112
+ this.cancel(el)
113
+
114
+ const startHeight = el.offsetHeight
115
+ const endHeight = expanded ? el.scrollHeight : 0
116
+ const { paddingTop, paddingBottom } = readVerticalPadding(el)
117
+
118
+ if (startHeight === endHeight) {
119
+ this.setExpanded(el, expanded)
120
+ return
121
+ }
122
+
123
+ setStyles(el, {
124
+ boxSizing: 'border-box',
125
+ height: `${startHeight}px`,
126
+ paddingTop,
127
+ paddingBottom,
128
+ overflow: 'hidden',
129
+ transition: getTransition(this.options, expanded ? 'enter' : 'leave'),
130
+ willChange: 'height'
131
+ })
132
+
133
+ void el.offsetHeight
134
+
135
+ setStyles(el, {
136
+ height: `${endHeight}px`,
137
+ paddingTop: expanded ? paddingTop : 0,
138
+ paddingBottom: expanded ? paddingBottom : 0
139
+ })
140
+
141
+ const cleanup = () => {
142
+ el.removeEventListener('transitionend', onEnd)
143
+ el.removeEventListener('transitioncancel', onEnd)
144
+ this.cleanupMap.delete(el)
145
+ }
146
+
147
+ const onEnd = (e: TransitionEvent) => {
148
+ if (e.target !== el || e.propertyName !== 'height') return
149
+ cleanup()
150
+ this.resetTemporaryStyles(el)
151
+ el.style.overflow = 'hidden'
152
+ el.style.height = expanded ? 'auto' : '0px'
153
+ }
154
+
155
+ el.addEventListener('transitionend', onEnd)
156
+ el.addEventListener('transitioncancel', onEnd)
157
+ this.cleanupMap.set(el, cleanup)
158
+ }
159
+
160
+ private resetTransitionStyles(el: HTMLElement) {
161
+ removeStyles(el, [
162
+ 'box-sizing',
163
+ 'height',
164
+ 'padding-top',
165
+ 'padding-bottom',
166
+ 'overflow',
167
+ 'transition',
168
+ 'opacity',
169
+ 'will-change'
170
+ ])
171
+ }
172
+
173
+ private resetTemporaryStyles(el: HTMLElement) {
174
+ removeStyles(el, [
175
+ 'box-sizing',
176
+ 'padding-top',
177
+ 'padding-bottom',
178
+ 'transition',
179
+ 'opacity',
180
+ 'will-change'
181
+ ])
182
+ }
183
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export * from './dom/class-name'
2
2
 
3
+ export * from './dom/expand-transition'
4
+
3
5
  export * from './dom/highlight'
4
6
 
5
7
  export * from './dom/position'