@awc-ui/core 1.0.0-beta.10 → 1.0.0-beta.12

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.
Files changed (66) hide show
  1. package/bin/templates/astro/README.md +2 -0
  2. package/bin/templates/astro/package.json +4 -4
  3. package/bin/templates/html/index.html +2 -2
  4. package/bin/templates/html/package.json +2 -2
  5. package/bin/templates/next/README.md +5 -2
  6. package/bin/templates/next/package.json +8 -8
  7. package/bin/templates/next/tsconfig.json +2 -2
  8. package/bin/templates/nuxt/package.json +2 -2
  9. package/bin/templates/sveltekit/package.json +2 -2
  10. package/custom-elements.json +8 -8
  11. package/dist/cjs/md-navigation-rail-tab.cjs.entry.js +185 -37
  12. package/dist/cjs/md-navigation-rail-tab.cjs.entry.js.map +1 -1
  13. package/dist/cjs/md-navigation-rail.cjs.entry.js +15 -16
  14. package/dist/cjs/md-navigation-rail.cjs.entry.js.map +1 -1
  15. package/dist/cjs/navigation-rail-motion-IOseZpRz.js +90 -0
  16. package/dist/cjs/navigation-rail-motion-IOseZpRz.js.map +1 -0
  17. package/dist/collection/components/md-navigation-rail/md-navigation-rail.css +90 -85
  18. package/dist/collection/components/md-navigation-rail/md-navigation-rail.js +14 -15
  19. package/dist/collection/components/md-navigation-rail/md-navigation-rail.js.map +1 -1
  20. package/dist/collection/components/md-navigation-rail-tab/md-navigation-rail-tab.css +8 -11
  21. package/dist/collection/components/md-navigation-rail-tab/md-navigation-rail-tab.js +184 -36
  22. package/dist/collection/components/md-navigation-rail-tab/md-navigation-rail-tab.js.map +1 -1
  23. package/dist/collection/utils/navigation-rail-motion.js +80 -0
  24. package/dist/collection/utils/navigation-rail-motion.js.map +1 -0
  25. package/dist/components/md-navigation-rail-tab.js +1 -1
  26. package/dist/components/md-navigation-rail-tab.js.map +1 -1
  27. package/dist/components/md-navigation-rail.js +1 -1
  28. package/dist/components/md-navigation-rail.js.map +1 -1
  29. package/dist/components/p-BTQOOQYQ.js +2 -0
  30. package/dist/components/p-BTQOOQYQ.js.map +1 -0
  31. package/dist/components-csr/md-navigation-rail-tab.js +1 -1
  32. package/dist/components-csr/md-navigation-rail-tab.js.map +1 -1
  33. package/dist/components-csr/md-navigation-rail.js +1 -1
  34. package/dist/components-csr/md-navigation-rail.js.map +1 -1
  35. package/dist/components-csr/p-BTQOOQYQ.js +2 -0
  36. package/dist/components-csr/p-BTQOOQYQ.js.map +1 -0
  37. package/dist/esm/md-navigation-rail-tab.entry.js +185 -37
  38. package/dist/esm/md-navigation-rail-tab.entry.js.map +1 -1
  39. package/dist/esm/md-navigation-rail.entry.js +15 -16
  40. package/dist/esm/md-navigation-rail.entry.js.map +1 -1
  41. package/dist/esm/navigation-rail-motion-BTQOOQYQ.js +84 -0
  42. package/dist/esm/navigation-rail-motion-BTQOOQYQ.js.map +1 -0
  43. package/dist/md3/md3.esm.js +1 -1
  44. package/dist/md3/p-88d49766.entry.js +2 -0
  45. package/dist/md3/p-88d49766.entry.js.map +1 -0
  46. package/dist/md3/p-BTQOOQYQ.js +2 -0
  47. package/dist/md3/p-BTQOOQYQ.js.map +1 -0
  48. package/dist/md3/p-c38e1473.entry.js +2 -0
  49. package/dist/md3/p-c38e1473.entry.js.map +1 -0
  50. package/dist/sources/{20812305a6a9bf6e0ac3bcaa49cf729c7e1f1d944f6467cc179222ebbfe7c036 → 0ff3480d9a3ad4d643358cd2d23546b2045fcacf8afbea2e0280ca4d1d7c2d09}/md-navigation-rail.tsx +12 -12
  51. package/dist/sources/{6c14a0a0c65f2aec64d62332f49f228b81d0cbbb288795543716266e9f70f444 → 2f4b6a7ed1aaf6a2a7b0e44123a4b5d30a18047aa5c2121896389bd6d11fcc66}/md-navigation-rail.css_tag_md-navigation-rail_encapsulation_shadow +90 -85
  52. package/dist/sources/{822acc654bb272fea6b5455c9869791448b125cae2c1eb1df0f97b27263a18f3 → 67e302a0ddf9f68ba82638d32533a0e69408184931f9f1268243f7246c714ae8}/md-navigation-rail-tab.css_tag_md-navigation-rail-tab_encapsulation_shadow +8 -11
  53. package/dist/sources/{4b1bf2d27bc87d12e416a1ae2bdc0f361033c40d13b252fe4c9f51a197ca95e1 → b3d606320b653bcbde553e6d61569edd0fc6cbdb1cb179f4d36ad796e76b133c}/md-navigation-rail-tab.tsx +208 -45
  54. package/dist/sources/f41e60167e8f17a6297648047951ef2d3dd96a3652adecfbe959df57bdef48dd/navigation-rail-motion.ts +97 -0
  55. package/dist/types/components/md-navigation-rail/md-navigation-rail.d.ts +3 -5
  56. package/dist/types/components/md-navigation-rail-tab/md-navigation-rail-tab.d.ts +17 -10
  57. package/dist/types/utils/navigation-rail-motion.d.ts +17 -0
  58. package/hydrate/index.js +278 -53
  59. package/hydrate/index.mjs +278 -53
  60. package/package.json +2 -2
  61. package/src/components/md-navigation-rail/readme.md +8 -0
  62. package/src/components/md-navigation-rail-tab/readme.md +6 -2
  63. package/dist/md3/p-7ca4c543.entry.js +0 -2
  64. package/dist/md3/p-7ca4c543.entry.js.map +0 -1
  65. package/dist/md3/p-9acadde5.entry.js +0 -2
  66. package/dist/md3/p-9acadde5.entry.js.map +0 -1
@@ -11,6 +11,7 @@ import {
11
11
  Method,
12
12
  } from '@stencil/core';
13
13
  import { triggerRipple } from '../../utils/ripple';
14
+ import { getRailTransitionTiming, parseRailMotionDuration, registerRailTabTransition, synchronizeRailAnimation } from '../../utils/navigation-rail-motion';
14
15
  import { sanitizeHref, SAFE_LINK_REL } from '../../utils/url';
15
16
 
16
17
  /**
@@ -92,9 +93,52 @@ export class MdNavigationRailTab {
92
93
  */
93
94
  @Event({ bubbles: true, composed: true }) mdSubmenuToggle: EventEmitter<{ open: boolean }>;
94
95
 
95
- /** Icon-wrapper centre (host-relative) captured before the layout switch. */
96
+ /** Rendered geometry captured before the rail or destination changes layout. */
96
97
  private iconFirst: { cx: number; cy: number } | null = null;
98
+ private indicatorFirst: { left: number; top: number; width: number; height: number } | null = null;
99
+ private labelFirst: {
100
+ rect: DOMRect;
101
+ opacity: number;
102
+ visible: boolean;
103
+ fontFamily: string;
104
+ fontSize: string;
105
+ fontWeight: string;
106
+ lineHeight: string;
107
+ letterSpacing: string;
108
+ paddingTop: string;
109
+ paddingRight: string;
110
+ paddingBottom: string;
111
+ paddingLeft: string;
112
+ textAlign: string;
113
+ boxSizing: string;
114
+ } | null = null;
115
+ private labelOverlay?: {
116
+ element: HTMLElement;
117
+ styles: Map<string, { value: string; priority: string }>;
118
+ };
97
119
  private pendingTransition = false;
120
+ private transitionPrepared = false;
121
+ private iconAnimation?: Animation;
122
+ private indicatorAnimation?: Animation;
123
+ private labelAnimation?: Animation;
124
+ private unregisterTransition?: () => void;
125
+
126
+ connectedCallback() {
127
+ this.unregisterTransition = registerRailTabTransition(this.el, () => {
128
+ this.captureTransition();
129
+ this.transitionPrepared = true;
130
+ });
131
+ }
132
+
133
+ disconnectedCallback() {
134
+ this.unregisterTransition?.();
135
+ this.cancelAnimations();
136
+ this.iconFirst = null;
137
+ this.indicatorFirst = null;
138
+ this.labelFirst = null;
139
+ this.pendingTransition = false;
140
+ this.transitionPrepared = false;
141
+ }
98
142
 
99
143
  /** Mirrors the slotted submenu's open state for `aria-expanded`. */
100
144
  @State() private submenuOpen: boolean = false;
@@ -131,20 +175,95 @@ export class MdNavigationRailTab {
131
175
  }
132
176
  }
133
177
 
134
- /**
135
- * The stacked (collapsed) and inline (expanded) layouts place the icon at
136
- * slightly different spots. To avoid an abrupt snap we FLIP it: capture the
137
- * old centre before the layout flips, then (in componentDidRender) glide it
138
- * to the new centre with a smooth DECELERATE curve — deliberately not the
139
- * rail's springy/overshoot easing, which made the small move look like a
140
- * floaty wobble. The label can't slide cleanly (its path cuts through the
141
- * icon), so it cross-fades into its new position instead.
142
- */
178
+ /** The rail prepares before its own padding changes. Direct prop changes
179
+ * still capture here, so a destination also behaves correctly in isolation. */
143
180
  @Watch('expanded')
144
181
  onExpandedChange() {
145
- if (this.prefersReducedMotion()) return;
182
+ if (!this.transitionPrepared) this.captureTransition();
183
+ this.transitionPrepared = false;
184
+ }
185
+
186
+ private captureTransition() {
187
+ const sr = this.el.shadowRoot;
188
+ const indicator = sr?.querySelector<HTMLElement>('[part="indicator"]');
189
+ const label = sr?.querySelector<HTMLElement>('[part="label"]');
190
+ // Read the visible (possibly mid-animation) boxes BEFORE cancelling. A
191
+ // reversal then starts at the pixels on screen, not the previous target.
146
192
  this.iconFirst = this.iconCentre();
147
- this.pendingTransition = true;
193
+ this.indicatorFirst = indicator?.getBoundingClientRect() ?? null;
194
+ const labelCss = label ? getComputedStyle(label) : null;
195
+ this.labelFirst = label && labelCss ? {
196
+ rect: label.getBoundingClientRect(),
197
+ opacity: Number.parseFloat(labelCss.opacity) || 0,
198
+ visible: labelCss.display !== 'none' && labelCss.visibility !== 'hidden',
199
+ fontFamily: labelCss.fontFamily,
200
+ fontSize: labelCss.fontSize,
201
+ fontWeight: labelCss.fontWeight,
202
+ lineHeight: labelCss.lineHeight,
203
+ letterSpacing: labelCss.letterSpacing,
204
+ paddingTop: labelCss.paddingTop,
205
+ paddingRight: labelCss.paddingRight,
206
+ paddingBottom: labelCss.paddingBottom,
207
+ paddingLeft: labelCss.paddingLeft,
208
+ textAlign: labelCss.textAlign,
209
+ boxSizing: labelCss.boxSizing,
210
+ } : null;
211
+ this.cancelAnimations();
212
+ this.pendingTransition = !this.prefersReducedMotion();
213
+ if (!this.pendingTransition) {
214
+ this.iconFirst = null;
215
+ this.indicatorFirst = null;
216
+ this.labelFirst = null;
217
+ }
218
+ }
219
+
220
+ private cancelAnimations() {
221
+ this.iconAnimation?.cancel();
222
+ this.indicatorAnimation?.cancel();
223
+ this.labelAnimation?.cancel();
224
+ this.iconAnimation = undefined;
225
+ this.indicatorAnimation = undefined;
226
+ this.labelAnimation = undefined;
227
+ this.restoreLabelOverlay();
228
+ }
229
+
230
+ private restoreLabelOverlay() {
231
+ if (!this.labelOverlay) return;
232
+ const { element, styles } = this.labelOverlay;
233
+ for (const [name, previous] of styles) {
234
+ if (previous.value) element.style.setProperty(name, previous.value, previous.priority);
235
+ else element.style.removeProperty(name);
236
+ }
237
+ this.labelOverlay = undefined;
238
+ }
239
+
240
+ private retainOutgoingLabel(label: HTMLElement, first: NonNullable<MdNavigationRailTab['labelFirst']>) {
241
+ const host = this.el.getBoundingClientRect();
242
+ const styles = new Map<string, { value: string; priority: string }>();
243
+ const overlay: Record<string, string> = {
244
+ display: 'inline-block', position: 'absolute',
245
+ left: `${first.rect.left - host.left - this.el.clientLeft + this.el.scrollLeft}px`,
246
+ top: `${first.rect.top - host.top - this.el.clientTop + this.el.scrollTop}px`,
247
+ width: `${first.rect.width}px`, height: `${first.rect.height}px`,
248
+ 'max-inline-size': 'none', 'max-block-size': 'none',
249
+ 'margin-top': '0', 'margin-right': '0', 'margin-bottom': '0', 'margin-left': '0',
250
+ transform: 'none', opacity: String(first.opacity), visibility: 'visible',
251
+ 'pointer-events': 'none', 'transition-property': 'none',
252
+ // Longhands preserve authored font/margin/padding/transition values when
253
+ // cleanup restores each property; clearing a shorthand would erase them.
254
+ 'font-family': first.fontFamily, 'font-size': first.fontSize,
255
+ 'font-weight': first.fontWeight, 'line-height': first.lineHeight,
256
+ 'letter-spacing': first.letterSpacing,
257
+ 'padding-top': first.paddingTop, 'padding-right': first.paddingRight,
258
+ 'padding-bottom': first.paddingBottom, 'padding-left': first.paddingLeft,
259
+ 'text-align': first.textAlign, 'box-sizing': first.boxSizing,
260
+ };
261
+ for (const [name, value] of Object.entries(overlay)) {
262
+ if (!value) continue;
263
+ styles.set(name, { value: label.style.getPropertyValue(name), priority: label.style.getPropertyPriority(name) });
264
+ label.style.setProperty(name, value);
265
+ }
266
+ this.labelOverlay = { element: label, styles };
148
267
  }
149
268
 
150
269
  componentDidRender() {
@@ -180,54 +299,98 @@ export class MdNavigationRailTab {
180
299
  const raw = getComputedStyle(this.el)
181
300
  .getPropertyValue('--md-sys-motion-duration-short4')
182
301
  .trim();
183
- if (raw.endsWith('ms')) return parseFloat(raw) || 200;
184
- if (raw.endsWith('s')) return (parseFloat(raw) || 0.2) * 1000;
185
- return 200;
302
+ return parseRailMotionDuration(raw);
186
303
  }
187
304
 
188
305
  private playTransition() {
189
306
  const sr = this.el.shadowRoot;
190
307
  if (!sr) return;
191
- const duration = this.motionDuration();
308
+ const fallbackEasing = getComputedStyle(this.el)
309
+ .getPropertyValue('--md-sys-motion-easing-standard').trim()
310
+ || 'cubic-bezier(0.2, 0, 0, 1)';
311
+ const { duration, easing, source } = getRailTransitionTiming(this.el, {
312
+ duration: this.motionDuration(), easing: fallbackEasing,
313
+ });
314
+ if (duration === 0) {
315
+ this.iconFirst = null;
316
+ this.indicatorFirst = null;
317
+ this.labelFirst = null;
318
+ return;
319
+ }
320
+ const timing: KeyframeAnimationOptions = { duration, easing, fill: 'none' };
192
321
 
193
- // Icon: glide from old centre to new with a smooth decelerate (no overshoot).
194
- const icon = sr.querySelector(
195
- '.md-navigation-rail-tab__icon-wrapper',
196
- ) as HTMLElement | null;
322
+ const icon = sr.querySelector<HTMLElement>('[part="icon-wrapper"]');
197
323
  if (icon && this.iconFirst) {
198
324
  const r = icon.getBoundingClientRect();
199
325
  const dx = this.iconFirst.cx - (r.left + r.width / 2);
200
326
  const dy = this.iconFirst.cy - (r.top + r.height / 2);
201
327
  if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
202
- icon.animate(
203
- [
204
- { transform: `translate(${dx}px, ${dy}px)` },
205
- { transform: 'translate(0, 0)' },
206
- ],
207
- {
208
- duration,
209
- // Standard easing — quick in, snappy settle, no bounce — matching
210
- // the rail width morph and the button shape-morph feel.
211
- easing:
212
- getComputedStyle(this.el)
213
- .getPropertyValue('--md-sys-motion-easing-standard')
214
- .trim() || 'cubic-bezier(0.2, 0, 0, 1)',
215
- fill: 'none',
216
- },
217
- );
328
+ this.iconAnimation = icon.animate([
329
+ { transform: `translate(${dx}px, ${dy}px)` },
330
+ { transform: 'translate(0, 0)' },
331
+ ], timing);
332
+ const animation = this.iconAnimation;
333
+ synchronizeRailAnimation(animation, source, () => this.iconAnimation === animation);
218
334
  }
219
335
  }
220
336
  this.iconFirst = null;
221
337
 
222
- // Label: cross-fade into its new layout position rather than sliding.
223
- const label = sr.querySelector(
224
- '.md-navigation-rail-tab__label',
225
- ) as HTMLElement | null;
226
- if (label) {
227
- label.animate(
228
- [{ opacity: 0 }, { opacity: 0, offset: 0.15 }, { opacity: 1 }],
229
- { duration, easing: 'ease-out', fill: 'none' },
230
- );
338
+ // CSS cannot interpolate a content-sized expanded pill to a fixed collapsed
339
+ // pill. Morph only its surface; scaling the whole destination distorts text.
340
+ const indicator = sr.querySelector<HTMLElement>('[part="indicator"]');
341
+ if (indicator && this.indicatorFirst) {
342
+ const first = this.indicatorFirst;
343
+ const last = indicator.getBoundingClientRect();
344
+ if (first.width > 0 && first.height > 0 && last.width > 0 && last.height > 0) {
345
+ this.indicatorAnimation = indicator.animate([
346
+ {
347
+ transform: `translate(${first.left - last.left}px, ${first.top - last.top}px) scale(${first.width / last.width}, ${first.height / last.height})`,
348
+ transformOrigin: 'top left',
349
+ },
350
+ { transform: 'translate(0, 0) scale(1, 1)', transformOrigin: 'top left' },
351
+ ], timing);
352
+ const animation = this.indicatorAnimation;
353
+ synchronizeRailAnimation(animation, source, () => this.indicatorAnimation === animation);
354
+ }
355
+ }
356
+ this.indicatorFirst = null;
357
+
358
+ const label = sr.querySelector<HTMLElement>('[part="label"]');
359
+ const first = this.labelFirst;
360
+ this.labelFirst = null;
361
+ if (label && first) {
362
+ const css = getComputedStyle(label);
363
+ const targetOpacity = Number.parseFloat(css.opacity) || 0;
364
+ const visible = css.display !== 'none' && css.visibility !== 'hidden' && targetOpacity > 0;
365
+ if (!visible && first.visible && first.opacity > 0 && first.rect.width > 0 && first.rect.height > 0) {
366
+ // Preserve only the outgoing label's painted box. It no longer takes
367
+ // layout space, so the collapsed icon and pill can reach their targets.
368
+ this.retainOutgoingLabel(label, first);
369
+ this.labelAnimation = label.animate([
370
+ { opacity: first.opacity }, { opacity: 0 },
371
+ ], { ...timing, fill: 'forwards' });
372
+ const animation = this.labelAnimation;
373
+ synchronizeRailAnimation(animation, source, () => this.labelAnimation === animation);
374
+ void animation.finished.then(() => {
375
+ if (this.labelAnimation !== animation) return;
376
+ this.restoreLabelOverlay();
377
+ this.labelAnimation = undefined;
378
+ animation.cancel();
379
+ }, () => undefined);
380
+ } else if (visible) {
381
+ const last = label.getBoundingClientRect();
382
+ const dx = first.visible ? first.rect.left - last.left : 0;
383
+ const dy = first.visible ? first.rect.top - last.top : 0;
384
+ const opacity = first.visible ? first.opacity : 0;
385
+ if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5 || Math.abs(opacity - targetOpacity) > 0.01) {
386
+ this.labelAnimation = label.animate([
387
+ { transform: `translate(${dx}px, ${dy}px)`, opacity },
388
+ { transform: 'translate(0, 0)', opacity: targetOpacity },
389
+ ], timing);
390
+ const animation = this.labelAnimation;
391
+ synchronizeRailAnimation(animation, source, () => this.labelAnimation === animation);
392
+ }
393
+ }
231
394
  }
232
395
  }
233
396
 
@@ -0,0 +1,97 @@
1
+ /** Private bridge for capturing tab geometry before its rail changes layout. */
2
+ const transitionPreparations = new WeakMap<HTMLElement, () => void>();
3
+
4
+ export function registerRailTabTransition(
5
+ element: HTMLElement,
6
+ callback: () => void,
7
+ ): () => void {
8
+ transitionPreparations.set(element, callback);
9
+ return () => {
10
+ // A disconnected instance must not unregister a newer connection's callback.
11
+ if (transitionPreparations.get(element) === callback) {
12
+ transitionPreparations.delete(element);
13
+ }
14
+ };
15
+ }
16
+
17
+ export function prepareRailTabTransition(element: HTMLElement): void {
18
+ transitionPreparations.get(element)?.();
19
+ }
20
+
21
+ export interface RailTransitionTiming {
22
+ duration: number;
23
+ easing: string;
24
+ /** The actual width transition, including its shortened reversal clock. */
25
+ source?: Animation;
26
+ }
27
+
28
+ export function parseRailMotionDuration(raw: string, fallback = 200): number {
29
+ const value = raw.trim();
30
+ if (!/^(?:\d*\.)?\d+(?:ms|s)?$/.test(value)) return fallback;
31
+ const duration = Number.parseFloat(value) * (value.endsWith('s') && !value.endsWith('ms') ? 1000 : 1);
32
+ return Number.isFinite(duration) && duration >= 0 ? duration : fallback;
33
+ }
34
+
35
+ /**
36
+ * CSS shortens a transition when it reverses. Reading the authored token alone
37
+ * would restart a destination's animation for the full duration while its rail
38
+ * had already finished closing. Use the browser's actual width-animation clock.
39
+ */
40
+ export function getRailTransitionTiming(
41
+ destination: HTMLElement,
42
+ fallback: RailTransitionTiming,
43
+ ): RailTransitionTiming {
44
+ const rail = destination.closest('md-navigation-rail') as HTMLElement | null;
45
+ if (!rail) return fallback;
46
+ const surface = rail.hasAttribute('modal')
47
+ ? rail.shadowRoot?.querySelector<HTMLElement>('[part="container"]') ?? rail
48
+ : rail;
49
+ // Flush the target layout before reading its generated CSS transitions.
50
+ surface.getBoundingClientRect();
51
+ const animations = typeof surface.getAnimations === 'function' ? surface.getAnimations() : [];
52
+ const source = animations.find((animation) => {
53
+ const property = (animation as Animation & { transitionProperty?: string }).transitionProperty;
54
+ return (property === 'width' || property === 'inline-size') && animation.playState !== 'finished';
55
+ });
56
+ if (source?.effect) {
57
+ const effect = source.effect as KeyframeEffect;
58
+ const timing = effect.getTiming();
59
+ const duration = effect.getComputedTiming().duration;
60
+ if (typeof duration === 'number' && Number.isFinite(duration) && duration >= 0) {
61
+ // CSS transitions may put their curve on the first keyframe instead of
62
+ // the effect; preserve that curve instead of silently substituting linear.
63
+ const keyframeEasing = effect.getKeyframes?.()[0]?.easing;
64
+ return {
65
+ duration,
66
+ easing: keyframeEasing && keyframeEasing !== 'linear'
67
+ ? keyframeEasing : timing.easing || fallback.easing,
68
+ source,
69
+ };
70
+ }
71
+ }
72
+ // A zero-duration transition does not appear in getAnimations().
73
+ const css = getComputedStyle(surface);
74
+ const properties = css.transitionProperty?.split(',').map((value) => value.trim()) ?? [];
75
+ const index = properties.findIndex((value) => ['all', 'width', 'inline-size'].includes(value));
76
+ const durations = css.transitionDuration?.split(',') ?? [];
77
+ if (properties.includes('none') || (index >= 0 && durations.length && parseRailMotionDuration(durations[index % durations.length]) === 0)) {
78
+ return { ...fallback, duration: 0 };
79
+ }
80
+ return fallback;
81
+ }
82
+
83
+ /** Align with a width transition that may still be waiting for its first frame. */
84
+ export function synchronizeRailAnimation(
85
+ animation: Animation,
86
+ source: Animation | undefined,
87
+ isCurrent: () => boolean,
88
+ ): void {
89
+ if (!source) return;
90
+ const align = () => {
91
+ if (isCurrent() && typeof source.startTime === 'number') animation.startTime = source.startTime;
92
+ };
93
+ align();
94
+ if (source.startTime === null && source.ready) {
95
+ void source.ready.then(align, () => undefined);
96
+ }
97
+ }
@@ -107,11 +107,9 @@ export declare class MdNavigationRail {
107
107
  * context so the fixed-position menu is not trapped inside it. */
108
108
  private submenuOpen;
109
109
  /**
110
- * Drives the *destination layout* (stacked vs. inline) independently of the
111
- * rail's own width. On expand it flips with the width so labels reveal as the
112
- * rail grows; on collapse it lingers in the inline layout until the width
113
- * animation has finished, then drops to the stacked resting layout — keeping
114
- * the label reveal/conceal in sync with the width instead of snapping.
110
+ * Destination target layout, synchronized with the variant before rendering.
111
+ * Tabs capture their current geometry before this changes, then animate to the
112
+ * new layout while the rail changes width.
115
113
  */
116
114
  private tabsExpanded;
117
115
  /** Programmatically expand the rail (shows labels inline with icons). */
@@ -61,9 +61,19 @@ export declare class MdNavigationRailTab {
61
61
  mdSubmenuToggle: EventEmitter<{
62
62
  open: boolean;
63
63
  }>;
64
- /** Icon-wrapper centre (host-relative) captured before the layout switch. */
64
+ /** Rendered geometry captured before the rail or destination changes layout. */
65
65
  private iconFirst;
66
+ private indicatorFirst;
67
+ private labelFirst;
68
+ private labelOverlay?;
66
69
  private pendingTransition;
70
+ private transitionPrepared;
71
+ private iconAnimation?;
72
+ private indicatorAnimation?;
73
+ private labelAnimation?;
74
+ private unregisterTransition?;
75
+ connectedCallback(): void;
76
+ disconnectedCallback(): void;
67
77
  /** Mirrors the slotted submenu's open state for `aria-expanded`. */
68
78
  private submenuOpen;
69
79
  /** True when a dropdown is slotted, so the label can show a disclosure caret. */
@@ -72,16 +82,13 @@ export declare class MdNavigationRailTab {
72
82
  * destination itself reads as the active one. */
73
83
  private submenuHasSelection;
74
84
  componentDidLoad(): void;
75
- /**
76
- * The stacked (collapsed) and inline (expanded) layouts place the icon at
77
- * slightly different spots. To avoid an abrupt snap we FLIP it: capture the
78
- * old centre before the layout flips, then (in componentDidRender) glide it
79
- * to the new centre with a smooth DECELERATE curve — deliberately not the
80
- * rail's springy/overshoot easing, which made the small move look like a
81
- * floaty wobble. The label can't slide cleanly (its path cuts through the
82
- * icon), so it cross-fades into its new position instead.
83
- */
85
+ /** The rail prepares before its own padding changes. Direct prop changes
86
+ * still capture here, so a destination also behaves correctly in isolation. */
84
87
  onExpandedChange(): void;
88
+ private captureTransition;
89
+ private cancelAnimations;
90
+ private restoreLabelOverlay;
91
+ private retainOutgoingLabel;
85
92
  componentDidRender(): void;
86
93
  private prefersReducedMotion;
87
94
  private iconCentre;
@@ -0,0 +1,17 @@
1
+ export declare function registerRailTabTransition(element: HTMLElement, callback: () => void): () => void;
2
+ export declare function prepareRailTabTransition(element: HTMLElement): void;
3
+ export interface RailTransitionTiming {
4
+ duration: number;
5
+ easing: string;
6
+ /** The actual width transition, including its shortened reversal clock. */
7
+ source?: Animation;
8
+ }
9
+ export declare function parseRailMotionDuration(raw: string, fallback?: number): number;
10
+ /**
11
+ * CSS shortens a transition when it reverses. Reading the authored token alone
12
+ * would restart a destination's animation for the full duration while its rail
13
+ * had already finished closing. Use the browser's actual width-animation clock.
14
+ */
15
+ export declare function getRailTransitionTiming(destination: HTMLElement, fallback: RailTransitionTiming): RailTransitionTiming;
16
+ /** Align with a width transition that may still be waiting for its first frame. */
17
+ export declare function synchronizeRailAnimation(animation: Animation, source: Animation | undefined, isCurrent: () => boolean): void;