@rdlabo/ionic-theme-ios27 1.2.0-0 → 1.2.0-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.
Files changed (78) hide show
  1. package/README.md +12 -2
  2. package/dist/css/components/ion-tabs.css +1 -1
  3. package/dist/css/ionic-theme-ios27.css +1 -1
  4. package/dist/css/vertical-bars.css +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +32 -12
  8. package/dist/native/components/index.d.ts.map +1 -1
  9. package/dist/native/components/index.js +4 -1
  10. package/dist/native/components/ion-button.d.ts.map +1 -1
  11. package/dist/native/components/ion-button.js +3 -3
  12. package/dist/native/components/ion-buttons.d.ts.map +1 -1
  13. package/dist/native/components/ion-buttons.js +3 -2
  14. package/dist/native/definitions.d.ts +8 -15
  15. package/dist/native/definitions.d.ts.map +1 -1
  16. package/dist/native/definitions.js +1 -6
  17. package/dist/native/index.d.ts +1 -2
  18. package/dist/native/index.d.ts.map +1 -1
  19. package/dist/native/index.js +64 -28
  20. package/dist/native/prehide.d.ts.map +1 -1
  21. package/dist/native/prehide.js +6 -4
  22. package/dist/native/runtime.d.ts.map +1 -1
  23. package/dist/native/runtime.js +27 -12
  24. package/dist/native/shared/candidate.d.ts.map +1 -1
  25. package/dist/native/shared/candidate.js +8 -0
  26. package/dist/native/shared/dom.d.ts.map +1 -1
  27. package/dist/native/shared/dom.js +11 -10
  28. package/dist/native/shared/modal.d.ts +8 -0
  29. package/dist/native/shared/modal.d.ts.map +1 -0
  30. package/dist/native/shared/modal.js +143 -0
  31. package/dist/native/vertical-bars-web.d.ts.map +1 -1
  32. package/dist/native/vertical-bars-web.js +11 -3
  33. package/dist/native-integration/index.d.ts.map +1 -1
  34. package/dist/native-integration/index.js +7 -2
  35. package/dist/native-integration/transition.d.ts +3 -0
  36. package/dist/native-integration/transition.d.ts.map +1 -0
  37. package/dist/native-integration/transition.js +7 -0
  38. package/dist/sheets-of-glass/animations.d.ts +13 -4
  39. package/dist/sheets-of-glass/animations.d.ts.map +1 -1
  40. package/dist/sheets-of-glass/animations.js +56 -40
  41. package/dist/sheets-of-glass/index.d.ts +1 -1
  42. package/dist/sheets-of-glass/index.d.ts.map +1 -1
  43. package/dist/sheets-of-glass/index.js +64 -20
  44. package/dist/transition/ios.transition.d.ts.map +1 -1
  45. package/dist/transition/ios.transition.js +2 -3
  46. package/dist/vertical-bars.d.ts +2 -2
  47. package/dist/vertical-bars.d.ts.map +1 -1
  48. package/dist/vertical-bars.js +1 -1
  49. package/docs/iphone-duo-with-original-theme.md +171 -0
  50. package/docs/iphone-duo.md +105 -50
  51. package/docs/migration.md +23 -0
  52. package/docs/native-ui-shell.md +10 -6
  53. package/docs/special-markup.md +3 -14
  54. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +23 -4
  55. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +15 -171
  56. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +5 -0
  57. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +30 -0
  58. package/package.json +1 -1
  59. package/src/index.ts +41 -9
  60. package/src/native/components/index.ts +6 -1
  61. package/src/native/components/ion-button.ts +4 -3
  62. package/src/native/components/ion-buttons.ts +4 -2
  63. package/src/native/definitions.ts +11 -19
  64. package/src/native/index.ts +67 -30
  65. package/src/native/prehide.ts +7 -4
  66. package/src/native/runtime.ts +28 -11
  67. package/src/native/shared/candidate.ts +10 -0
  68. package/src/native/shared/dom.ts +11 -10
  69. package/src/native/shared/modal.ts +157 -0
  70. package/src/native/vertical-bars-web.ts +12 -3
  71. package/src/native-integration/index.ts +8 -2
  72. package/src/native-integration/transition.ts +15 -0
  73. package/src/sheets-of-glass/animations.ts +66 -41
  74. package/src/sheets-of-glass/index.ts +82 -16
  75. package/src/styles/components/ion-tabs.scss +7 -4
  76. package/src/styles/vertical-bars.scss +102 -35
  77. package/src/transition/ios.transition.ts +4 -4
  78. package/src/vertical-bars.ts +2 -2
@@ -44,13 +44,19 @@ export const isNativeUIShell = (element: HTMLElement) => element.hasAttribute('d
44
44
  export const suspendNativeUIShell = async (scopes: HTMLElement[]): Promise<(canceled?: boolean) => void> =>
45
45
  (await runtimes.get(scopes[0]?.ownerDocument)?.suspend(scopes)) ?? (() => {});
46
46
 
47
+ const connectedTransitions = new WeakSet<Animation>();
48
+ const cancellationTransitions = new WeakSet<Animation>();
49
+
47
50
  /** Ionic write hooks cannot await the bridge. Gate playback, including interactive playback. */
48
51
  export const connectNativeUIShellTransition = (animation: Animation, entering: HTMLElement, leaving?: HTMLElement) => {
49
- if (leaving)
52
+ if (leaving && !cancellationTransitions.has(animation)) {
53
+ cancellationTransitions.add(animation);
50
54
  animation.onFinish((step) => {
51
55
  if (step === 0) leaving.dispatchEvent(new CustomEvent(VERTICAL_BARS_TRANSITION_CANCELED, { bubbles: true, detail: { entering } }));
52
56
  });
53
- if (!runtimes.has(entering.ownerDocument)) return;
57
+ }
58
+ if (connectedTransitions.has(animation) || !runtimes.has(entering.ownerDocument)) return;
59
+ connectedTransitions.add(animation);
54
60
  const scopes = leaving ? [entering, leaving] : [entering];
55
61
  const play = animation.play.bind(animation);
56
62
  const progressStart = animation.progressStart.bind(animation);
@@ -0,0 +1,15 @@
1
+ import type { AnimationBuilder } from '@ionic/core';
2
+ import { connectNativeUIShellTransition } from './index';
3
+
4
+ /**
5
+ * Keeps an existing Ionic navigation animation and coordinates native control handoff.
6
+ * Register the returned builder as navAnimation. Do not use it for overlay animations.
7
+ * The builder must return a fresh Animation for each navigation; Ionic destroys it afterward.
8
+ */
9
+ export const withNativeUIShellTransition =
10
+ (builder: AnimationBuilder): AnimationBuilder =>
11
+ (baseEl, opts) => {
12
+ const animation = builder(baseEl, opts);
13
+ if (opts?.enteringEl) connectNativeUIShellTransition(animation, opts.enteringEl, opts.leavingEl);
14
+ return animation;
15
+ };
@@ -3,6 +3,21 @@ import { AnimationPosition, EffectScales } from './interfaces';
3
3
  import { createAnimation, GestureDetail } from '@ionic/core';
4
4
  import { getStep } from '../utils';
5
5
 
6
+ // Work in main/cross-axis coordinates so native horizontal samples also drive the rail.
7
+ export const animationRect = (element: Element, vertical = false) => {
8
+ const rect = element.getBoundingClientRect();
9
+ return vertical
10
+ ? { left: rect.top, top: rect.left, right: rect.bottom, width: rect.height, height: rect.width, x: rect.y, y: rect.x }
11
+ : rect;
12
+ };
13
+ const translate = (x: number, y: number, vertical: boolean) => `translate3d(${vertical ? y : x}px, ${vertical ? x : y}px, 0)`;
14
+ const stretchTransform = (x: number, y: number, vertical: boolean) => `scale(${vertical ? y : x}, ${vertical ? x : y})`;
15
+ const mainSize = (element: HTMLElement, vertical: boolean) => (vertical ? element.offsetHeight : element.offsetWidth);
16
+ const axisScale = (transform: string, vertical: boolean) => {
17
+ const matrix = new DOMMatrixReadOnly(transform);
18
+ return vertical ? { a: matrix.d, d: matrix.a } : matrix;
19
+ };
20
+
6
21
  export const getScaleAnimation = (effectElement: Element): Animation => {
7
22
  return createAnimation().addElement(effectElement.shadowRoot!.querySelector<HTMLElement>('[part="native"]')!).easing('ease-out');
8
23
  };
@@ -46,55 +61,60 @@ const tabBarTapFrames = [
46
61
  ];
47
62
 
48
63
  // UITabBarController presentation-layer samples: horizontal stretch precedes vertical stretch.
49
- export const createTabBarPressAnimation = (effect: HTMLElement, from: HTMLElement, to: HTMLElement, bar: HTMLElement): Animation => {
50
- const box = bar.getBoundingClientRect();
64
+ export const createTabBarPressAnimation = (
65
+ effect: HTMLElement,
66
+ from: HTMLElement,
67
+ to: HTMLElement,
68
+ bar: HTMLElement,
69
+ vertical = false,
70
+ ): Animation => {
71
+ const box = animationRect(bar, vertical);
51
72
  const center = box.left + box.width / 2;
52
- const left = center - bar.offsetWidth / 2;
53
- const fromBox = from.getBoundingClientRect();
54
- const toBox = to.getBoundingClientRect();
55
- const parentScale = box.width / bar.offsetWidth;
56
- const buttonScale = new DOMMatrixReadOnly(getComputedStyle(from).transform);
73
+ const left = center - mainSize(bar, vertical) / 2;
74
+ const fromBox = animationRect(from, vertical);
75
+ const toBox = animationRect(to, vertical);
76
+ const parentScale = box.width / mainSize(bar, vertical);
77
+ const buttonScale = axisScale(getComputedStyle(from).transform, vertical);
57
78
  const width = fromBox.width / parentScale / buttonScale.a;
58
79
  const height = fromBox.height / parentScale / buttonScale.d;
59
80
  const start = (fromBox.left + fromBox.width / 2 - box.left) / parentScale;
60
81
  const end = (toBox.left + toBox.width / 2 - box.left) / parentScale;
61
82
  const selected = from === to;
83
+ const growth = vertical ? 20 / 16 : 1;
62
84
  const frames = tabBarPressFrames.map(([time, position, extraWidth, extraHeight, expansion]) => {
63
- const scale = 1 + (14.14 / bar.offsetWidth) * expansion;
85
+ const scale = vertical ? 1 : 1 + (14.14 / mainSize(bar, vertical)) * expansion;
64
86
  // A selected item expands in place, without the transfer's sideways deformation.
65
87
  const press = 1 - Math.exp(-18 * time) * (1 + 18 * time);
66
88
  return {
67
89
  offset: time / 0.9,
68
90
  x: center + (left + start + (end - start) * position - center) * scale - width / 2,
69
- sx: ((width + (selected ? 16 * press : extraWidth)) * scale) / width,
70
- sy: ((height + (selected ? 16 * press : extraHeight)) * scale) / height,
91
+ sx: ((width + (selected ? 16 * press : extraWidth) * growth) * scale) / width,
92
+ sy: ((height + (selected ? 16 * press : extraHeight) * growth) * scale) / height,
71
93
  };
72
94
  });
73
95
  const move = createAnimation()
74
96
  .addElement(effect)
75
97
  .beforeStyles({
76
- width: `${width}px`,
77
- height: `${height}px`,
98
+ width: `${vertical ? height : width}px`,
99
+ height: `${vertical ? width : height}px`,
78
100
  display: 'block',
79
101
  opacity: '1',
80
102
  })
81
- .keyframes(
82
- frames.map(({ offset, x }) => ({ offset, transform: `translate3d(${x}px, ${box.top + box.height / 2 - height / 2}px, 0)` })),
83
- );
103
+ .keyframes(frames.map(({ offset, x }) => ({ offset, transform: translate(x, box.top + box.height / 2 - height / 2, vertical) })));
84
104
  const stretch = getScaleAnimation(effect)
85
105
  .easing('linear')
86
- .keyframes(frames.map(({ offset, sx, sy }) => ({ offset, transform: `scale(${sx}, ${sy})` })));
106
+ .keyframes(frames.map(({ offset, sx, sy }) => ({ offset, transform: stretchTransform(sx, sy, vertical) })));
87
107
  return createAnimation().duration(900).easing('linear').addAnimation([move, stretch]);
88
108
  };
89
109
 
90
110
  // Drag samples: faster movement stretches sideways, then rebounds vertically at rest.
91
- export const createTabBarDragAnimation = (effect: HTMLElement, bar: HTMLElement, velocity: number): Animation => {
111
+ export const createTabBarDragAnimation = (effect: HTMLElement, bar: HTMLElement, velocity: number, vertical = false): Animation => {
92
112
  const native = effect.shadowRoot!.querySelector<HTMLElement>('[part="native"]')!;
93
- const current = new DOMMatrixReadOnly(native.style.transform || getComputedStyle(native).transform);
94
- const { width, height } = effect.getBoundingClientRect();
95
- const scale = 1 + 14.14 / bar.offsetWidth;
96
- const restX = ((width + 16) * scale) / width;
97
- const restY = ((height + 16) * scale) / height;
113
+ const current = axisScale(native.style.transform || getComputedStyle(native).transform, vertical);
114
+ const { width, height } = animationRect(effect, vertical);
115
+ const scale = vertical ? 1 : 1 + 14.14 / mainSize(bar, vertical);
116
+ const restX = ((width + (vertical ? 20 : 16)) * scale) / width;
117
+ const restY = ((height + (vertical ? 20 : 16)) * scale) / height;
98
118
  // Approximate the 100/600pt/s UIKit samples; keep deformation bounded for fast swipes.
99
119
  const stretch = Math.min(16, 32 * velocity * velocity) * scale;
100
120
  const rebound = Math.max(stretch, (current.a - restX) * width) * 0.8;
@@ -102,10 +122,10 @@ export const createTabBarDragAnimation = (effect: HTMLElement, bar: HTMLElement,
102
122
  .duration(500)
103
123
  .easing('linear')
104
124
  .keyframes([
105
- { offset: 0, transform: `scale(${current.a}, ${current.d})` },
106
- { offset: 0.2, transform: `scale(${restX + stretch / width}, ${restY - stretch / height})` },
107
- { offset: 0.44, transform: `scale(${restX - rebound / width}, ${restY + rebound / height})` },
108
- { offset: 1, transform: `scale(${restX}, ${restY})` },
125
+ { offset: 0, transform: stretchTransform(current.a, current.d, vertical) },
126
+ { offset: 0.2, transform: stretchTransform(restX + stretch / width, restY - stretch / height, vertical) },
127
+ { offset: 0.44, transform: stretchTransform(restX - rebound / width, restY + rebound / height, vertical) },
128
+ { offset: 1, transform: stretchTransform(restX, restY, vertical) },
109
129
  ]);
110
130
  };
111
131
 
@@ -114,10 +134,11 @@ export const createTabBarReleaseAnimation = (
114
134
  effectElement: Element,
115
135
  target: HTMLElement,
116
136
  tap?: { elapsed: number; distance: number },
137
+ vertical = false,
117
138
  ): Animation => {
118
139
  const tapElapsed = tap?.elapsed;
119
140
  const native = effectElement.shadowRoot!.querySelector<HTMLElement>('[part="native"]')!;
120
- const scale = new DOMMatrixReadOnly(native.style.transform || getComputedStyle(native).transform);
141
+ const scale = axisScale(native.style.transform || getComputedStyle(native).transform, vertical);
121
142
  const samples = [
122
143
  [0, 1],
123
144
  [0.033, 0.85],
@@ -131,22 +152,25 @@ export const createTabBarReleaseAnimation = (
131
152
  [0.45, 0],
132
153
  ];
133
154
  const bar = target.parentElement!;
134
- const box = bar.getBoundingClientRect();
135
- const current = effectElement.getBoundingClientRect();
136
- const targetBox = target.getBoundingClientRect();
155
+ const box = animationRect(bar, vertical);
156
+ const current = animationRect(effectElement, vertical);
157
+ const targetBox = animationRect(target, vertical);
137
158
  const center = box.left + box.width / 2;
138
- const x = center + (targetBox.left + targetBox.width / 2 - center) / (box.width / bar.offsetWidth) - current.width / 2;
159
+ const x = center + (targetBox.left + targetBox.width / 2 - center) / (box.width / mainSize(bar, vertical)) - current.width / 2;
139
160
  const y = box.top + box.height / 2 - current.height / 2;
161
+ // The vertical platter contracts from 64pt items to 44 × 58pt on release.
162
+ const endScaleX = vertical ? target.offsetHeight / current.width : 1;
163
+ const endScaleY = vertical ? target.offsetWidth / current.height : 1;
140
164
  const elapsed = (tapElapsed ?? 0) / 1000;
141
165
  const duration = tapElapsed === undefined ? 450 : 800 - tapElapsed;
142
166
  let frames = samples.map(([time, remaining]) => ({
143
167
  offset: time / 0.45,
144
168
  x: x + (current.x - x) * remaining,
145
169
  y: y + (current.y - y) * remaining,
146
- sx: 1 + (scale.a - 1) * remaining,
147
- sy: 1 + (scale.d - 1) * remaining,
170
+ sx: endScaleX + (scale.a - endScaleX) * remaining,
171
+ sy: endScaleY + (scale.d - endScaleY) * remaining,
148
172
  }));
149
- if (tapElapsed !== undefined) {
173
+ if (tapElapsed !== undefined && !vertical) {
150
174
  const nextIndex = tabBarTapFrames.findIndex(([time]) => time > elapsed);
151
175
  const previous = tabBarTapFrames[Math.max(0, nextIndex - 1)];
152
176
  const next = tabBarTapFrames[nextIndex];
@@ -159,7 +183,7 @@ export const createTabBarReleaseAnimation = (
159
183
  ...tabBarTapFrames.slice(nextIndex).map(([time, position, width, height]) => ({
160
184
  offset: (time - elapsed) / (0.8 - elapsed),
161
185
  x: x + ((current.x - x) * (1 - position)) / Math.max(0.1, 1 - progress),
162
- y,
186
+ y: y + ((current.y - y) * (1 - position)) / Math.max(0.1, 1 - progress),
163
187
  sx: 1 + (width * (time >= 0.367 ? 1 + 0.75 * extraDistance : 1)) / current.width,
164
188
  sy: 1 + (height * (time >= 0.367 ? 1 + 0.5 * extraDistance : 1)) / current.height,
165
189
  })),
@@ -169,7 +193,7 @@ export const createTabBarReleaseAnimation = (
169
193
  .addElement(native)
170
194
  .duration(duration)
171
195
  .easing('linear')
172
- .keyframes(frames.map(({ offset, sx, sy }) => ({ offset, transform: `scale(${sx}, ${sy})` })));
196
+ .keyframes(frames.map(({ offset, sx, sy }) => ({ offset, transform: stretchTransform(sx, sy, vertical) })));
173
197
  const move = createAnimation()
174
198
  .addElement(effectElement)
175
199
  .duration(duration)
@@ -177,7 +201,7 @@ export const createTabBarReleaseAnimation = (
177
201
  .keyframes(
178
202
  frames.map(({ offset, x, y }) => ({
179
203
  offset,
180
- transform: `translate3d(${x}px, ${y}px, 0)`,
204
+ transform: translate(x, y, vertical),
181
205
  })),
182
206
  );
183
207
  // Blend the reflective lens into the selected surface during the final 200ms.
@@ -201,10 +225,10 @@ export const createTabBarReleaseAnimation = (
201
225
  { offset: 1, backgroundColor: selectedBackground },
202
226
  ]);
203
227
  const release = createAnimation().duration(duration).addAnimation([stretch, move, glass, selected]);
204
- if (tapElapsed !== undefined) {
228
+ if (tapElapsed !== undefined && !vertical) {
205
229
  // A short UIKit tap keeps expanding after lift-off, then settles independently of the lens.
206
230
  // Width deltas from the 50ms tap; preserve the rendered size for an uninterrupted handoff.
207
- const width = bar.offsetWidth;
231
+ const width = mainSize(bar, vertical);
208
232
  const expansion = box.width - width;
209
233
  const peak = Math.max(8.65, expansion);
210
234
  const platter = createAnimation()
@@ -282,6 +306,7 @@ export const createMoveAnimation = (
282
306
  detail: GestureDetail,
283
307
  tabSelectedElement: Element,
284
308
  animationPosition: AnimationPosition,
309
+ vertical = false,
285
310
  ): Animation => {
286
311
  return createAnimation()
287
312
  .duration(500)
@@ -295,8 +320,8 @@ export const createMoveAnimation = (
295
320
  })
296
321
  .fromTo(
297
322
  'transform',
298
- `translate3d(${animationPosition.minPositionX}px, ${animationPosition.positionY}px, 0)`,
299
- `translate3d(${animationPosition.maxPositionX}px, ${animationPosition.positionY}px, 0)`,
323
+ translate(animationPosition.minPositionX, animationPosition.positionY, vertical),
324
+ translate(animationPosition.maxPositionX, animationPosition.positionY, vertical),
300
325
  )
301
326
  .progressStep(getStep(detail.currentX, animationPosition));
302
327
  };
@@ -4,6 +4,7 @@ import type { Animation, Gesture, GestureDetail } from '@ionic/core';
4
4
  import { changeSelectedElement, cloneElement, getStep } from '../utils';
5
5
  import { isNativeUIShell } from '../native-integration';
6
6
  import {
7
+ animationRect,
7
8
  createMoveAnimation,
8
9
  createPreMoveAnimation,
9
10
  createTabBarDragAnimation,
@@ -21,6 +22,7 @@ export const registerEffect = (
21
22
  effectTagName: string,
22
23
  selectedClassName: string,
23
24
  scales: EffectScales,
25
+ vertical = false,
24
26
  ): registeredEffect | undefined => {
25
27
  if (!targetElement.classList.contains('ios')) {
26
28
  return undefined;
@@ -42,6 +44,7 @@ export const registerEffect = (
42
44
  let destroyed = false;
43
45
  const originalTabBarScale = targetElement.style.getPropertyValue('--ios27-tab-bar-scale');
44
46
  const effectElement = cloneElement(effectTagName, false);
47
+ if (vertical) effectElement.classList.add('ios27-vertical-tab-effect');
45
48
  const stopTabPress = () => {
46
49
  if (!tabPressAnimation) return;
47
50
  const native = effectElement.shadowRoot!.querySelector<HTMLElement>('[part="native"]')!;
@@ -121,18 +124,39 @@ export const registerEffect = (
121
124
  document.addEventListener('pointercancel', onPointerCancel);
122
125
  };
123
126
  targetElement.addEventListener('pointerdown', onPointerDown);
127
+ const onClick = (event: MouseEvent) => {
128
+ // A drag commits its destination itself; the browser may click the old pressed tab.
129
+ if (vertical && tabDragging && event.isTrusted && event.detail > 0) {
130
+ event.preventDefault();
131
+ event.stopImmediatePropagation();
132
+ }
133
+ };
134
+ targetElement.addEventListener('click', onClick, true);
124
135
 
136
+ const axisDetail = (detail: GestureDetail): GestureDetail =>
137
+ vertical
138
+ ? {
139
+ ...detail,
140
+ currentX: detail.currentY,
141
+ currentY: detail.currentX,
142
+ startX: detail.startY,
143
+ startY: detail.startX,
144
+ velocityX: detail.velocityY,
145
+ velocityY: detail.velocityX,
146
+ }
147
+ : detail;
125
148
  const createAnimationGesture = () => {
126
149
  targetElement.classList.add(GESTURE_NAME);
127
150
  gesture = createGesture({
128
151
  el: targetElement,
129
152
  threshold: 0,
153
+ direction: vertical ? 'y' : 'x',
130
154
  gestureName: `${GESTURE_NAME}_${effectTagName}_${crypto.randomUUID()}`,
131
- onStart: (event) => onStartGesture(event),
132
- onMove: (event) => onMoveGesture(event),
155
+ onStart: (event) => onStartGesture(axisDetail(event)),
156
+ onMove: (event) => onMoveGesture(axisDetail(event)),
133
157
  onEnd: (detail) => {
134
158
  // Ionic coalesces moves; consume the release coordinate before committing a drag.
135
- if (effectTagName === 'ion-tab-button' && tabDragging) onMoveGesture(detail);
159
+ if (effectTagName === 'ion-tab-button' && tabDragging) onMoveGesture(axisDetail(detail));
136
160
  onEndGesture().then();
137
161
  },
138
162
  });
@@ -160,34 +184,49 @@ export const registerEffect = (
160
184
  if (isNativeUIShell(targetElement)) return false;
161
185
  currentTouchedElement = ((detail.event.target as HTMLElement).closest(effectTagName) as HTMLElement) || undefined;
162
186
  const tabSelectedElement = targetElement.querySelector(`${effectTagName}.${selectedClassName}`);
163
- if (currentTouchedElement === undefined || tabSelectedElement === null) {
187
+ if (
188
+ currentTouchedElement === undefined ||
189
+ currentTouchedElement.parentElement !== targetElement ||
190
+ (currentTouchedElement as HTMLIonTabButtonElement).disabled ||
191
+ tabSelectedElement === null
192
+ ) {
193
+ currentTouchedElement = undefined;
164
194
  return false;
165
195
  }
166
196
  selectedElementBeforeGesture = tabSelectedElement as HTMLElement;
167
197
  if (effectTagName === 'ion-tab-button') {
168
198
  // UIKit expands the platter by 14.14pt at both two- and four-tab widths.
169
- targetElement.style.setProperty('--ios27-tab-bar-scale', String(1 + 14.14 / targetElement.offsetWidth));
199
+ targetElement.style.setProperty(
200
+ '--ios27-tab-bar-scale',
201
+ String(1 + 14.14 / (vertical ? targetElement.offsetHeight : targetElement.offsetWidth)),
202
+ );
170
203
  }
204
+ if (vertical) targetElement.classList.add(ANIMATED_NAME);
171
205
  animationPosition = {
172
- minPositionX: targetElement.getBoundingClientRect().left,
173
- maxPositionX: targetElement.getBoundingClientRect().right - tabSelectedElement.clientWidth,
174
- width: tabSelectedElement.clientWidth,
175
- positionY: tabSelectedElement.getBoundingClientRect().top,
206
+ minPositionX: animationRect(targetElement, vertical).left,
207
+ maxPositionX:
208
+ animationRect(targetElement, vertical).right - (vertical ? tabSelectedElement.clientHeight : tabSelectedElement.clientWidth),
209
+ width: vertical ? tabSelectedElement.clientHeight : tabSelectedElement.clientWidth,
210
+ positionY: animationRect(tabSelectedElement, vertical).top,
176
211
  };
177
212
  targetElement.classList.add(ANIMATED_NAME);
178
213
  changeSelectedElement(targetElement, currentTouchedElement, effectTagName, selectedClassName);
179
214
 
180
215
  if (effectTagName === 'ion-tab-button') {
181
- moveAnimation = createMoveAnimation(effectElement, detail, tabSelectedElement, animationPosition);
216
+ moveAnimation = createMoveAnimation(effectElement, detail, tabSelectedElement, animationPosition, vertical);
182
217
  moveAnimation.progressStart(
183
218
  true,
184
- getStep(tabSelectedElement.getBoundingClientRect().left + tabSelectedElement.clientWidth / 2, animationPosition),
219
+ getStep(
220
+ animationRect(tabSelectedElement, vertical).left + animationRect(tabSelectedElement, vertical).width / 2,
221
+ animationPosition,
222
+ ),
185
223
  );
186
224
  tabPressAnimation = createTabBarPressAnimation(
187
225
  effectElement,
188
226
  tabSelectedElement as HTMLElement,
189
227
  currentTouchedElement,
190
228
  targetElement,
229
+ vertical,
191
230
  );
192
231
  tabPressStartedAt = performance.now();
193
232
  void tabPressAnimation.play();
@@ -225,7 +264,7 @@ export const registerEffect = (
225
264
  if (Math.abs(detail.currentX - detail.startX) < 3 && !tabDragging) return;
226
265
  tabDragging = true;
227
266
  stopTabPress();
228
- tabPressAnimation = createTabBarDragAnimation(effectElement, targetElement, detail.velocityX);
267
+ tabPressAnimation = createTabBarDragAnimation(effectElement, targetElement, detail.velocityX, vertical);
229
268
  void tabPressAnimation.play();
230
269
  } else if (scaleAnimationPromise === undefined) {
231
270
  if (Math.abs(detail.velocityX) > maxVelocity) {
@@ -249,11 +288,32 @@ export const registerEffect = (
249
288
  }
250
289
 
251
290
  const currentX = detail.currentX;
252
- const previousY = targetElement.getBoundingClientRect().top + targetElement.getBoundingClientRect().height / 2;
253
- const nextEl = (targetElement.getRootNode() as Document | ShadowRoot).elementFromPoint(currentX, previousY);
254
- const latestTouchedElement = (nextEl?.closest(effectTagName) as HTMLElement) || undefined;
291
+ const previousY = animationRect(targetElement, vertical).top + animationRect(targetElement, vertical).height / 2;
292
+ const nextEl = (targetElement.getRootNode() as Document | ShadowRoot).elementFromPoint(
293
+ vertical ? previousY : currentX,
294
+ vertical ? currentX : previousY,
295
+ );
296
+ let latestTouchedElement: HTMLElement | undefined = (nextEl?.closest(effectTagName) as HTMLElement) || undefined;
297
+ if (vertical && effectTagName === 'ion-tab-button') {
298
+ // Overlapping, scaled tabs must not steal a drag from their neighbor.
299
+ const bar = targetElement.getBoundingClientRect();
300
+ const tabs = Array.from(targetElement.querySelectorAll<HTMLElement>(effectTagName)).filter((tab) => tab.offsetWidth > 0);
301
+ const distance = (tab: HTMLElement) => Math.abs(currentX - (bar.top + tab.offsetTop + tab.offsetHeight / 2));
302
+ latestTouchedElement =
303
+ currentX >= bar.top && currentX <= bar.bottom
304
+ ? tabs.reduce<HTMLElement | undefined>(
305
+ (closest, tab) => (!closest || distance(tab) < distance(closest) ? tab : closest),
306
+ undefined,
307
+ )
308
+ : undefined;
309
+ }
255
310
 
256
- if (latestTouchedElement && currentTouchedElement !== latestTouchedElement) {
311
+ if (
312
+ latestTouchedElement &&
313
+ latestTouchedElement.parentElement === targetElement &&
314
+ !(latestTouchedElement as HTMLIonTabButtonElement).disabled &&
315
+ currentTouchedElement !== latestTouchedElement
316
+ ) {
257
317
  currentTouchedElement = latestTouchedElement;
258
318
  changeSelectedElement(targetElement, currentTouchedElement, effectTagName, selectedClassName);
259
319
  }
@@ -308,6 +368,10 @@ export const registerEffect = (
308
368
  moveAnimation.destroy();
309
369
  moveAnimation = undefined;
310
370
  const tabs = Array.from(targetElement.querySelectorAll<HTMLElement>(effectTagName)).filter((tab) => tab.offsetWidth > 0);
371
+ if (vertical) {
372
+ currentTouchedElement.classList.remove('ion-activated');
373
+ targetElement.classList.remove(ANIMATED_NAME);
374
+ }
311
375
  const releasing = createTabBarReleaseAnimation(
312
376
  effectElement,
313
377
  currentTouchedElement,
@@ -317,6 +381,7 @@ export const registerEffect = (
317
381
  distance: Math.abs(tabs.indexOf(currentTouchedElement) - tabs.indexOf(selectedElementBeforeGesture!)),
318
382
  }
319
383
  : undefined,
384
+ vertical,
320
385
  );
321
386
  currentTouchedElement.classList.remove('ion-activated');
322
387
  releaseAnimation = releasing;
@@ -347,6 +412,7 @@ export const registerEffect = (
347
412
  cancelActiveGesture();
348
413
  // Remove event listeners
349
414
  targetElement.removeEventListener('pointerdown', onPointerDown);
415
+ targetElement.removeEventListener('click', onClick, true);
350
416
  targetElement.removeEventListener('nativeUIShellChange', nativeChanged);
351
417
 
352
418
  // Destroy gesture
@@ -70,11 +70,14 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) {
70
70
  height: 54px;
71
71
  min-height: 54px;
72
72
  &:has(> ion-tab-button:nth-child(2):last-child) {
73
- width: 180px; // 188px including the 4px inset on each side.
73
+ width: var(--ios-theme-side-tab-content-width, 180px); // 188px including the 4px inset on each side.
74
74
  }
75
75
  &:has(:nth-child(5)) {
76
- width: calc(100% - calc(18px + var(--ion-safe-area-left, 0px)) - calc(18px + var(--ion-safe-area-right, 0px)) - 3px);
77
- max-width: 543px;
76
+ width: var(
77
+ --ios-theme-side-tab-content-width,
78
+ calc(100% - calc(18px + var(--ion-safe-area-left, 0px)) - calc(18px + var(--ion-safe-area-right, 0px)) - 3px)
79
+ );
80
+ max-width: var(--ios-theme-side-tab-content-width, 543px);
78
81
  }
79
82
  padding: 4px;
80
83
 
@@ -96,7 +99,7 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) {
96
99
  1 of ion-tab-button:not(.ion-cloned-element)
97
100
  )
98
101
  ) {
99
- max-width: $width - 8px;
102
+ max-width: var(--ios-theme-side-tab-content-width, #{$width - 8px});
100
103
  }
101
104
  }
102
105
  }