@kubex/zinc 1.1.119 → 1.1.121

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.
@@ -362,7 +362,7 @@ This ensures forms remain usable on all devices without additional configuration
362
362
 
363
363
  The label column sticks to the top of the scroll container, so on a long form it stays beside the inputs instead of scrolling away. Use the `--zn-form-group-sticky-top` custom property to change the offset it settles at — useful when the scroll container has a sticky header of its own.
364
364
 
365
- A container that clips its overflow but never scrolls — a `zn-panel` body sized to its content, say — would otherwise catch the label and hold it still for the whole scroll. The group clips such a container instead (`overflow: clip`, which clips without being a scroll container), so the label follows the box that is actually scrolled. Scrolling goes straight back to the container the moment its content outgrows it.
365
+ Where a container clips its overflow but never scrolls — a `zn-panel` body sized to its content, say — native sticky anchors to it and the label would sit still for the whole scroll. The group follows the container that is actually scrolled instead, off a scroll timeline where the browser has them so the compositor keeps the label with the fields, and off a scroll handler where it does not.
366
366
 
367
367
  ```html:preview
368
368
  <div style="max-height: 300px; overflow-y: auto;">
@@ -62,8 +62,8 @@ select's accessible name — it is not shown, since the caption names the sectio
62
62
  ### Pre-filled Values
63
63
 
64
64
  Set initial translations on each child. A language every child has a value for is marked `Translated`; one only some
65
- children have is `Partial`; one no child has falls back to English. English itself is the source, so it is neither
66
- counted nor marked as a translation.
65
+ children have is `Partial`; one no child has falls back to English. English itself is marked `Empty` rather than
66
+ falling back, having nothing to fall back to, and counts towards the total like any other language.
67
67
 
68
68
  ```html:preview
69
69
  <zn-translation-group
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.119",
3
+ "version": "1.1.121",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -45,16 +45,21 @@ export default class ZnFormGroup extends ZincElement {
45
45
 
46
46
  @property({ attribute: 'pad', type: Boolean }) pad: boolean = false;
47
47
 
48
- /** The scroll containers we have clipped, against the inline overflow each carried before. */
49
- private readonly clipped = new Map<HTMLElement, { x: string; y: string }>();
48
+ /** The scroller the label is moved against by hand; null while native sticky is enough. */
49
+ private tracked: HTMLElement | null = null;
50
+ /** Set while the compositor is running the movement off a scroll timeline instead. */
51
+ private animation: Animation | null = null;
52
+ private stickyTop: number = 0;
50
53
  private frame: number = 0;
54
+ private rebind: boolean = false;
55
+ private offset: number = 0;
51
56
  private resizeObserver: ResizeObserver | null = null;
52
57
 
53
58
  connectedCallback() {
54
59
  super.connectedCallback();
55
60
 
56
61
  // Whether an ancestor scrolls depends on how tall this form has grown.
57
- this.resizeObserver ??= new ResizeObserver(() => this.schedule());
62
+ this.resizeObserver ??= new ResizeObserver(() => this.schedule(true));
58
63
  this.resizeObserver.observe(this);
59
64
  window.addEventListener('resize', this.onViewportResize);
60
65
  }
@@ -63,93 +68,198 @@ export default class ZnFormGroup extends ZincElement {
63
68
  super.disconnectedCallback();
64
69
  this.resizeObserver?.disconnect();
65
70
  window.removeEventListener('resize', this.onViewportResize);
66
- this.release([...this.clipped.keys()]);
71
+ this.detach();
67
72
  cancelAnimationFrame(this.frame);
68
73
  this.frame = 0;
69
74
  }
70
75
 
71
76
  protected firstUpdated(changedProperties: PropertyValues) {
72
77
  super.firstUpdated(changedProperties);
73
- this.schedule();
78
+ this.schedule(true);
74
79
  }
75
80
 
76
81
  private get labelColumn(): HTMLElement | null {
77
82
  return this.shadowRoot?.querySelector('.form-control__text') ?? null;
78
83
  }
79
84
 
80
- /** Coalesces resize work into one frame, and out of the ResizeObserver callback. */
81
- private schedule() {
85
+ /** Coalesces scroll and resize work into one frame, and out of the ResizeObserver callback. */
86
+ private schedule(rebind: boolean = false) {
87
+ this.rebind ||= rebind;
82
88
  if (this.frame) return;
83
89
 
84
90
  this.frame = requestAnimationFrame(() => {
85
91
  this.frame = 0;
86
- this.freeSticky();
92
+ if (this.rebind) {
93
+ this.rebind = false;
94
+ this.bind();
95
+ }
96
+ this.positionLabel();
87
97
  });
88
98
  }
89
99
 
90
- // A shorter viewport can make an ancestor scroll without changing this form's size.
91
- private readonly onViewportResize = () => this.schedule();
92
-
93
100
  /**
94
- * Native sticky anchors to the nearest scroll container, even one that cannot scroll — a `zn-panel` body sized to
95
- * its content, say — where it then holds the label still for the whole scroll. `overflow: clip` clips without
96
- * making a scroll container, so clipping those takes them out of sticky's search and the label follows the box the
97
- * user actually scrolls, moved by the compositor rather than by hand.
101
+ * Native sticky only follows the nearest scroll container. Where that container isn't the one
102
+ * the user actually scrolls — a `zn-panel` body sized to its content inside a scrolling
103
+ * slideout, say — the label never moves, so it is moved against the real scroller instead.
98
104
  */
99
- private freeSticky() {
105
+ private bind() {
100
106
  const column = this.labelColumn;
101
107
  if (!column) return;
102
108
 
103
- const dead: HTMLElement[] = [];
109
+ // Measured with nothing of ours on the label, so what follows reads its resting position.
110
+ this.detach();
111
+ column.style.top = '';
112
+ this.stickyTop = parseFloat(getComputedStyle(column).top) || 0;
113
+
114
+ const anchor = this.nearestScrollContainer(column);
115
+ if (!anchor) return;
116
+
117
+ const scroller = this.scrollingAncestor(column);
118
+ if (scroller === anchor) return;
119
+
120
+ // A `top` inset against a box that never scrolls has nothing to hold the label back from:
121
+ // it only pushes the label down the page, so drop it and stand in for sticky below.
122
+ column.style.top = '0px';
123
+ if (!scroller) return;
124
+
125
+ this.tracked = scroller;
126
+
127
+ /*
128
+ * Scrolling is composited, so a transform written from a scroll handler lands a frame late and the label swims
129
+ * against the fields. A scroll timeline hands the same movement to the compositor; the handler is the fallback
130
+ * for browsers without one.
131
+ */
132
+ this.animation = this.scrollLinkedTravel(scroller);
133
+ if (this.animation) {
134
+ this.animation.play();
135
+ // Content arriving above the form moves the scroll position the label has to start from, and nothing resizes
136
+ // to say so. Re-measured at the end of a gesture, which is the soonest it can be seen.
137
+ this.scrollTarget(scroller)?.addEventListener('scrollend', this.onScrollEnd, { passive: true });
138
+ return;
139
+ }
104
140
 
105
- for (const element of this.ancestors(column)) {
106
- if (!this.clipped.has(element) && !this.isStickyAnchor(element)) continue;
141
+ this.scrollTarget(scroller)?.addEventListener('scroll', this.onScroll, { passive: true });
142
+ }
107
143
 
108
- // Scroll size reports the overflow through a clip, so a box that has grown into needing to scroll is handed
109
- // straight back — and sticky anchors to it, which is now the right answer.
110
- if (this.overflows(element)) break;
144
+ /**
145
+ * The label's whole journey, as the scroll positions it turns at: still until the fieldset's top reaches the sticky
146
+ * line, then a pixel for every pixel of scroll until it has crossed the fieldset. `fill: both` holds it at either
147
+ * end, which is the clamp the scroll handler applies by hand.
148
+ */
149
+ private scrollLinkedTravel(scroller: HTMLElement): Animation | null {
150
+ const column = this.labelColumn;
151
+ const fieldset = this.fieldset;
152
+ if (!column || !fieldset || typeof ScrollTimeline === 'undefined') return null;
153
+
154
+ // The stylesheet drops sticky while the columns are stacked, and there is nothing to follow.
155
+ if (getComputedStyle(column).position !== 'sticky') return null;
156
+
157
+ const travel = Math.max(0, fieldset.clientHeight - column.offsetHeight);
158
+ const range = scroller.scrollHeight - scroller.clientHeight;
159
+ if (travel < 1 || range < 1) return null;
160
+
161
+ const start = scroller.scrollTop + column.getBoundingClientRect().top
162
+ - this.visibleTop(scroller) - this.stickyTop;
163
+ const held = (scroll: number) => Math.min(Math.max(scroll - start, 0), travel);
164
+
165
+ const knees = [start, start + travel].filter(scroll => scroll > 0 && scroll < range);
166
+ const keyframes = [0, ...knees, range].map(scroll => ({
167
+ offset: Math.min(Math.max(scroll / range, 0), 1),
168
+ transform: `translateY(${held(scroll)}px)`
169
+ }));
170
+
171
+ return new Animation(
172
+ new KeyframeEffect(column, keyframes, { fill: 'both' }),
173
+ new ScrollTimeline({ source: scroller, axis: 'block' })
174
+ );
175
+ }
111
176
 
112
- dead.push(element);
113
- }
177
+ /** Drops everything this component has put on the label or on the scroller. */
178
+ private detach() {
179
+ const target = this.scrollTarget(this.tracked);
180
+ target?.removeEventListener('scroll', this.onScroll);
181
+ target?.removeEventListener('scrollend', this.onScrollEnd);
182
+ this.tracked = null;
183
+
184
+ this.animation?.cancel();
185
+ this.animation = null;
114
186
 
115
- this.release([...this.clipped.keys()].filter(element => !dead.includes(element)));
116
- dead.filter(element => !this.clipped.has(element)).forEach(element => this.clip(element));
187
+ const column = this.labelColumn;
188
+ if (!column) return;
189
+ this.offset = 0;
190
+ column.style.transform = '';
117
191
  }
118
192
 
119
- private clip(element: HTMLElement) {
120
- // A second form group in the same container finds the first one's clip already inline. Recording it as what was
121
- // there before would leave the box clipped for good, so it counts as nothing to put back.
122
- const kept = (overflow: string) => overflow === 'clip' ? '' : overflow;
123
- this.clipped.set(element, { x: kept(element.style.overflowX), y: kept(element.style.overflowY) });
124
- element.style.overflowX = 'clip';
125
- element.style.overflowY = 'clip';
193
+ /** The document scrolls through the window, every other scroller reports its own events. */
194
+ private scrollTarget(scroller: HTMLElement | null): EventTarget | null {
195
+ if (!scroller) return null;
196
+ return scroller === document.scrollingElement ? window : scroller;
197
+ }
126
198
 
127
- // Scrolling has to go back the moment the box is short enough to need it.
128
- this.resizeObserver?.observe(element);
199
+ /** Where the scroller's own top edge sits, which for the document is the top of the viewport. */
200
+ private visibleTop(scroller: HTMLElement) {
201
+ return scroller === document.scrollingElement ? 0 : scroller.getBoundingClientRect().top;
129
202
  }
130
203
 
131
- private release(elements: HTMLElement[]) {
132
- elements.forEach(element => {
133
- const inline = this.clipped.get(element);
134
- element.style.overflowX = inline?.x ?? '';
135
- element.style.overflowY = inline?.y ?? '';
136
- this.resizeObserver?.unobserve(element);
137
- this.clipped.delete(element);
138
- });
204
+ private get fieldset(): HTMLElement | null {
205
+ return this.shadowRoot?.querySelector('.form-control') ?? null;
206
+ }
207
+
208
+ private readonly onScroll = () => this.schedule();
209
+
210
+ private readonly onScrollEnd = () => this.schedule(true);
211
+
212
+ // A shorter viewport can make an ancestor scrollable without changing this form's size.
213
+ private readonly onViewportResize = () => this.schedule(true);
214
+
215
+ private positionLabel() {
216
+ if (this.animation) return;
217
+
218
+ const column = this.labelColumn;
219
+ const fieldset = this.fieldset;
220
+ if (!column || !fieldset) return;
221
+
222
+ let offset = 0;
223
+
224
+ // The stylesheet drops sticky while the columns are stacked; tracking has to stand down too.
225
+ if (this.tracked && getComputedStyle(column).position === 'sticky') {
226
+ const visibleTop = this.visibleTop(this.tracked);
227
+ const restingTop = column.getBoundingClientRect().top - this.offset;
228
+ const travel = Math.max(0, fieldset.clientHeight - column.offsetHeight);
229
+
230
+ offset = Math.min(Math.max(visibleTop + this.stickyTop - restingTop, 0), travel);
231
+ }
232
+
233
+ if (Math.round(offset) === Math.round(this.offset)) return;
234
+
235
+ this.offset = offset;
236
+ column.style.transform = offset ? `translateY(${offset}px)` : '';
139
237
  }
140
238
 
141
239
  /** The box native sticky would anchor to, whether or not it can be scrolled. */
142
- private isStickyAnchor(element: HTMLElement) {
143
- const style = getComputedStyle(element);
144
- return this.isScrollContainer(style.overflowY) || this.isScrollContainer(style.overflowX);
240
+ private nearestScrollContainer(from: HTMLElement): HTMLElement | null {
241
+ return this.ancestors(from).find(element => {
242
+ const style = getComputedStyle(element);
243
+ return this.isScrollContainer(style.overflowY) || this.isScrollContainer(style.overflowX);
244
+ }) ?? null;
145
245
  }
146
246
 
147
- private isScrollContainer(overflow: string) {
148
- return overflow === 'auto' || overflow === 'scroll' || overflow === 'hidden' || overflow === 'overlay';
247
+ /** The nearest ancestor the user can actually scroll, falling back to the document. */
248
+ private scrollingAncestor(from: HTMLElement): HTMLElement | null {
249
+ const scroller = this.ancestors(from).find(element => {
250
+ const overflow = getComputedStyle(element).overflowY;
251
+ return (overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay')
252
+ && element.scrollHeight > element.clientHeight + 1;
253
+ });
254
+
255
+ if (scroller) return scroller;
256
+
257
+ const root = document.scrollingElement as HTMLElement | null;
258
+ return root && root.scrollHeight > root.clientHeight + 1 ? root : null;
149
259
  }
150
260
 
151
- private overflows(element: HTMLElement) {
152
- return element.scrollHeight > element.clientHeight + 1 || element.scrollWidth > element.clientWidth + 1;
261
+ private isScrollContainer(overflow: string) {
262
+ return overflow === 'auto' || overflow === 'scroll' || overflow === 'hidden' || overflow === 'overlay';
153
263
  }
154
264
 
155
265
  /** Walks the flattened tree, so slots and shadow boundaries are crossed the way layout does. */
@@ -78,9 +78,8 @@ describe('<zn-form-group>', () => {
78
78
  });
79
79
 
80
80
  it('leaves the label level with the inputs while nothing scrolls', async () => {
81
- // Held clear of the viewport's top edge, which sticky's own inset holds the label back from wherever it is.
82
81
  const el = await fixture<HTMLElement>(html`
83
- <div style="width: 900px; padding-top: 60px">
82
+ <div style="width: 900px">
84
83
  <zn-panel>
85
84
  <zn-form-group label="Sticky"><zn-input label="Name"></zn-input></zn-form-group>
86
85
  </zn-panel>
@@ -94,23 +93,24 @@ describe('<zn-form-group>', () => {
94
93
  expect(label).to.be.closeTo(inputs, 2);
95
94
  });
96
95
 
97
- it('clips a scroll container that cannot scroll, and hands it back when it can', async () => {
96
+ it('hands the movement to a scroll timeline where the browser has one', async () => {
97
+ if (!window.ScrollTimeline) return;
98
+
98
99
  const el = await fixture<HTMLElement>(html`
99
- <div style="width: 900px; max-height: 300px; overflow-y: auto">
100
+ <div style="max-height: 300px; overflow-y: auto">
100
101
  <zn-panel>
101
102
  <zn-form-group label="Sticky"></zn-form-group>
102
103
  </zn-panel>
103
104
  </div>`);
104
- el.querySelector('zn-form-group')!.innerHTML = tallForm;
105
+ const group = el.querySelector<HTMLElement>('zn-form-group')!;
106
+ group.innerHTML = tallForm;
105
107
  await new Promise(resolve => setTimeout(resolve, 400));
106
108
 
107
- const body = el.querySelector('zn-panel')!.shadowRoot!.querySelector<HTMLElement>('.panel__body')!;
108
- expect(getComputedStyle(body).overflowY, 'sticky has to skip a body that cannot scroll').to.equal('clip');
109
-
110
- body.style.maxHeight = '120px';
111
- await new Promise(resolve => setTimeout(resolve, 400));
109
+ const label = group.shadowRoot!.querySelector<HTMLElement>('.form-control__text')!;
110
+ const [animation] = label.getAnimations();
112
111
 
113
- expect(getComputedStyle(body).overflowY, 'and it scrolls again once it must').to.equal('auto');
112
+ expect(animation, 'the compositor drives the label, not the scroll handler').to.exist;
113
+ expect(animation.timeline).to.be.instanceOf(window.ScrollTimeline);
114
114
  });
115
115
 
116
116
  it('holds the label in view when a panel sits between the form and the scroll container', async () => {
@@ -32,8 +32,8 @@ import styles from './translation-group.scss';
32
32
  * - `Partial` — only some children do
33
33
  * - `English` — none do, so all of them fall back to the English text
34
34
  *
35
- * `Empty` replaces the last of those for English itself, which has nothing to fall back to. English is the source
36
- * rather than a translation, so it is also left out of the `n of m translated` count beside the label.
35
+ * `Empty` replaces the last of those for English itself, which has nothing to fall back to. English counts towards
36
+ * the `n of m` beside the label like any other language.
37
37
  *
38
38
  * The children own their values; this component only chooses which language is shown and reports on what they hold.
39
39
  * It reads them back on every child `zn-change`, so the chips and the count follow an edit as it is typed.
@@ -221,14 +221,12 @@ export default class ZnTranslationGroup extends ZnPanel {
221
221
  const languageCodes = [...Object.keys(this.languages), ...extra];
222
222
  const hasMultipleLanguages = languageCodes.length > 1;
223
223
 
224
- // English is the source every other language falls back to, so it is not itself one of the translations counted.
225
- const targets = languageCodes.filter(code => code !== 'en');
226
- const translated = targets.filter(code => this.languageState(code).type === 'success').length;
227
224
  // Closed, the select answers "how much is left to do" rather than the state of the one language on show — that
228
225
  // is what the options are for.
226
+ const translated = languageCodes.filter(code => this.languageState(code).type === 'success').length;
229
227
  const summary = {
230
- label: `${translated}/${targets.length}`,
231
- type: translated === targets.length ? 'success' : translated > 0 ? 'warning' : 'error'
228
+ label: `${translated}/${languageCodes.length}`,
229
+ type: translated === languageCodes.length ? 'success' : translated > 0 ? 'warning' : 'error'
232
230
  };
233
231
 
234
232
  return html`
@@ -188,7 +188,7 @@ describe('<zn-translation-group>', () => {
188
188
  expect(label.textContent?.trim(), 'nothing else rides the caption').to.equal('Content');
189
189
 
190
190
  const summary = selectOf(group).querySelector('zn-chip[slot="suffix"]')!;
191
- expect(summary.textContent?.trim()).to.equal('1/1');
191
+ expect(summary.textContent?.trim()).to.equal('2/2');
192
192
  });
193
193
 
194
194
  it('wears the same chrome as any other select', async () => {
@@ -327,19 +327,19 @@ describe('<zn-translation-group>', () => {
327
327
  ['{"en":"Hello","fr":"Bonjour","de":"Hallo"}', '{"en":"Hi","fr":"Salut"}']);
328
328
 
329
329
  const summary = selectOf(group).querySelector('zn-chip[slot="suffix"]')!;
330
- expect(summary.textContent?.trim()).to.equal('1/3');
330
+ expect(summary.textContent?.trim()).to.equal('2/4');
331
331
  expect(summary.getAttribute('type')).to.equal('warning');
332
332
 
333
333
  expect(chipsOf(group)).to.deep.equal(['Translated', 'Translated', 'Partial', 'English']);
334
334
  });
335
335
 
336
- it('marks the summary chip done once every target language is translated', async () => {
336
+ it('marks the summary chip done once every language is translated', async () => {
337
337
  const group = await groupFixture(
338
338
  {en: 'English', fr: 'French'},
339
339
  ['{"en":"Hello","fr":"Bonjour"}']);
340
340
 
341
341
  const summary = selectOf(group).querySelector('zn-chip[slot="suffix"]')!;
342
- expect(summary.textContent?.trim()).to.equal('1/1');
342
+ expect(summary.textContent?.trim()).to.equal('2/2');
343
343
  expect(summary.getAttribute('type')).to.equal('success');
344
344
  });
345
345
 
@@ -351,13 +351,13 @@ describe('<zn-translation-group>', () => {
351
351
  expect(chipsOf(group)).to.deep.equal(['Translated', 'English']);
352
352
  });
353
353
 
354
- it('counts the target languages, not English', async () => {
354
+ it('counts English alongside the target languages', async () => {
355
355
  const group = await groupFixture(
356
356
  {en: 'English', fr: 'French', de: 'German'},
357
357
  ['{"en":"Hello","fr":"Bonjour"}']);
358
358
 
359
359
  const summary = selectOf(group).querySelector('zn-chip[slot="suffix"]')!;
360
- expect(summary.textContent?.trim()).to.equal('1/2');
360
+ expect(summary.textContent?.trim()).to.equal('2/3');
361
361
  });
362
362
 
363
363
  it('switches every child when the select changes', async () => {
@@ -416,14 +416,14 @@ describe('<zn-translation-group>', () => {
416
416
  await group.updateComplete;
417
417
 
418
418
  const summary = () => group.shadowRoot!.querySelector('zn-select zn-chip[slot="suffix"]')!.textContent?.trim();
419
- expect(summary()).to.equal('0/1');
419
+ expect(summary()).to.equal('1/2');
420
420
 
421
421
  form.reset();
422
422
  await new Promise(resolve => requestAnimationFrame(() => resolve(null)));
423
423
  await group.updateComplete;
424
424
 
425
425
  expect(child.values).to.deep.equal({en: 'Hello', fr: 'Bonjour'});
426
- expect(summary()).to.equal('1/1');
426
+ expect(summary()).to.equal('2/2');
427
427
  });
428
428
  });
429
429
  });
@@ -445,11 +445,10 @@ export default class ZnTranslations extends ZincElement implements ZincFormContr
445
445
  ];
446
446
  // Closed, the select answers "how much is left to do" rather than the state of the one language on show — that
447
447
  // is what the options are for.
448
- const targets = languageCodes.filter(code => code !== 'en');
449
- const translated = targets.filter(code => this.hasTranslation(code)).length;
448
+ const translated = languageCodes.filter(code => this.hasTranslation(code)).length;
450
449
  const summary = {
451
- label: `${translated}/${targets.length}`,
452
- type: translated === targets.length ? 'success' : translated > 0 ? 'warning' : 'error'
450
+ label: `${translated}/${languageCodes.length}`,
451
+ type: translated === languageCodes.length ? 'success' : translated > 0 ? 'warning' : 'error'
453
452
  };
454
453
 
455
454
  const currentTranslation = this.values[this._activeLanguage] ?? '';
@@ -95,7 +95,7 @@ describe('<zn-translations>', () => {
95
95
  await el.updateComplete;
96
96
 
97
97
  const summary = selectOf(el)!.querySelector('zn-chip[slot="suffix"]')!;
98
- expect(summary.textContent?.trim()).to.equal('1/2');
98
+ expect(summary.textContent?.trim()).to.equal('2/3');
99
99
  expect(summary.getAttribute('type')).to.equal('warning');
100
100
 
101
101
  const options = [...selectOf(el)!.querySelectorAll('zn-option zn-chip')];
@@ -37,3 +37,25 @@ declare interface Window {
37
37
  }
38
38
 
39
39
  /* eslint-enable */
40
+
41
+ /* eslint-disable */
42
+ interface ScrollTimelineOptions {
43
+ source?: Element | null;
44
+ axis?: 'block' | 'inline' | 'x' | 'y';
45
+ }
46
+
47
+ interface ScrollTimeline extends AnimationTimeline {
48
+ readonly source: Element | null;
49
+ readonly axis: string;
50
+ }
51
+
52
+ declare const ScrollTimeline: {
53
+ prototype: ScrollTimeline;
54
+ new(options?: ScrollTimelineOptions): ScrollTimeline;
55
+ };
56
+
57
+ declare interface Window {
58
+ ScrollTimeline?: typeof ScrollTimeline;
59
+ }
60
+
61
+ /* eslint-enable */