@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
@@ -1,5 +1,18 @@
1
1
  import { createAnimation } from '@ionic/core';
2
2
  import { getStep } from '../utils';
3
+ export const animationRect = (element, vertical = false) => {
4
+ const rect = element.getBoundingClientRect();
5
+ return vertical
6
+ ? { left: rect.top, top: rect.left, right: rect.bottom, width: rect.height, height: rect.width, x: rect.y, y: rect.x }
7
+ : rect;
8
+ };
9
+ const translate = (x, y, vertical) => `translate3d(${vertical ? y : x}px, ${vertical ? x : y}px, 0)`;
10
+ const stretchTransform = (x, y, vertical) => `scale(${vertical ? y : x}, ${vertical ? x : y})`;
11
+ const mainSize = (element, vertical) => (vertical ? element.offsetHeight : element.offsetWidth);
12
+ const axisScale = (transform, vertical) => {
13
+ const matrix = new DOMMatrixReadOnly(transform);
14
+ return vertical ? { a: matrix.d, d: matrix.a } : matrix;
15
+ };
3
16
  export const getScaleAnimation = (effectElement) => {
4
17
  return createAnimation().addElement(effectElement.shadowRoot.querySelector('[part="native"]')).easing('ease-out');
5
18
  };
@@ -37,66 +50,67 @@ const tabBarTapFrames = [
37
50
  [0.7, 1.003, -0.74, 0.59],
38
51
  [0.8, 1, 0, 0],
39
52
  ];
40
- export const createTabBarPressAnimation = (effect, from, to, bar) => {
41
- const box = bar.getBoundingClientRect();
53
+ export const createTabBarPressAnimation = (effect, from, to, bar, vertical = false) => {
54
+ const box = animationRect(bar, vertical);
42
55
  const center = box.left + box.width / 2;
43
- const left = center - bar.offsetWidth / 2;
44
- const fromBox = from.getBoundingClientRect();
45
- const toBox = to.getBoundingClientRect();
46
- const parentScale = box.width / bar.offsetWidth;
47
- const buttonScale = new DOMMatrixReadOnly(getComputedStyle(from).transform);
56
+ const left = center - mainSize(bar, vertical) / 2;
57
+ const fromBox = animationRect(from, vertical);
58
+ const toBox = animationRect(to, vertical);
59
+ const parentScale = box.width / mainSize(bar, vertical);
60
+ const buttonScale = axisScale(getComputedStyle(from).transform, vertical);
48
61
  const width = fromBox.width / parentScale / buttonScale.a;
49
62
  const height = fromBox.height / parentScale / buttonScale.d;
50
63
  const start = (fromBox.left + fromBox.width / 2 - box.left) / parentScale;
51
64
  const end = (toBox.left + toBox.width / 2 - box.left) / parentScale;
52
65
  const selected = from === to;
66
+ const growth = vertical ? 20 / 16 : 1;
53
67
  const frames = tabBarPressFrames.map(([time, position, extraWidth, extraHeight, expansion]) => {
54
- const scale = 1 + (14.14 / bar.offsetWidth) * expansion;
68
+ const scale = vertical ? 1 : 1 + (14.14 / mainSize(bar, vertical)) * expansion;
55
69
  const press = 1 - Math.exp(-18 * time) * (1 + 18 * time);
56
70
  return {
57
71
  offset: time / 0.9,
58
72
  x: center + (left + start + (end - start) * position - center) * scale - width / 2,
59
- sx: ((width + (selected ? 16 * press : extraWidth)) * scale) / width,
60
- sy: ((height + (selected ? 16 * press : extraHeight)) * scale) / height,
73
+ sx: ((width + (selected ? 16 * press : extraWidth) * growth) * scale) / width,
74
+ sy: ((height + (selected ? 16 * press : extraHeight) * growth) * scale) / height,
61
75
  };
62
76
  });
63
77
  const move = createAnimation()
64
78
  .addElement(effect)
65
79
  .beforeStyles({
66
- width: `${width}px`,
67
- height: `${height}px`,
80
+ width: `${vertical ? height : width}px`,
81
+ height: `${vertical ? width : height}px`,
68
82
  display: 'block',
69
83
  opacity: '1',
70
84
  })
71
- .keyframes(frames.map(({ offset, x }) => ({ offset, transform: `translate3d(${x}px, ${box.top + box.height / 2 - height / 2}px, 0)` })));
85
+ .keyframes(frames.map(({ offset, x }) => ({ offset, transform: translate(x, box.top + box.height / 2 - height / 2, vertical) })));
72
86
  const stretch = getScaleAnimation(effect)
73
87
  .easing('linear')
74
- .keyframes(frames.map(({ offset, sx, sy }) => ({ offset, transform: `scale(${sx}, ${sy})` })));
88
+ .keyframes(frames.map(({ offset, sx, sy }) => ({ offset, transform: stretchTransform(sx, sy, vertical) })));
75
89
  return createAnimation().duration(900).easing('linear').addAnimation([move, stretch]);
76
90
  };
77
- export const createTabBarDragAnimation = (effect, bar, velocity) => {
91
+ export const createTabBarDragAnimation = (effect, bar, velocity, vertical = false) => {
78
92
  const native = effect.shadowRoot.querySelector('[part="native"]');
79
- const current = new DOMMatrixReadOnly(native.style.transform || getComputedStyle(native).transform);
80
- const { width, height } = effect.getBoundingClientRect();
81
- const scale = 1 + 14.14 / bar.offsetWidth;
82
- const restX = ((width + 16) * scale) / width;
83
- const restY = ((height + 16) * scale) / height;
93
+ const current = axisScale(native.style.transform || getComputedStyle(native).transform, vertical);
94
+ const { width, height } = animationRect(effect, vertical);
95
+ const scale = vertical ? 1 : 1 + 14.14 / mainSize(bar, vertical);
96
+ const restX = ((width + (vertical ? 20 : 16)) * scale) / width;
97
+ const restY = ((height + (vertical ? 20 : 16)) * scale) / height;
84
98
  const stretch = Math.min(16, 32 * velocity * velocity) * scale;
85
99
  const rebound = Math.max(stretch, (current.a - restX) * width) * 0.8;
86
100
  return getScaleAnimation(effect)
87
101
  .duration(500)
88
102
  .easing('linear')
89
103
  .keyframes([
90
- { offset: 0, transform: `scale(${current.a}, ${current.d})` },
91
- { offset: 0.2, transform: `scale(${restX + stretch / width}, ${restY - stretch / height})` },
92
- { offset: 0.44, transform: `scale(${restX - rebound / width}, ${restY + rebound / height})` },
93
- { offset: 1, transform: `scale(${restX}, ${restY})` },
104
+ { offset: 0, transform: stretchTransform(current.a, current.d, vertical) },
105
+ { offset: 0.2, transform: stretchTransform(restX + stretch / width, restY - stretch / height, vertical) },
106
+ { offset: 0.44, transform: stretchTransform(restX - rebound / width, restY + rebound / height, vertical) },
107
+ { offset: 1, transform: stretchTransform(restX, restY, vertical) },
94
108
  ]);
95
109
  };
96
- export const createTabBarReleaseAnimation = (effectElement, target, tap) => {
110
+ export const createTabBarReleaseAnimation = (effectElement, target, tap, vertical = false) => {
97
111
  const tapElapsed = tap?.elapsed;
98
112
  const native = effectElement.shadowRoot.querySelector('[part="native"]');
99
- const scale = new DOMMatrixReadOnly(native.style.transform || getComputedStyle(native).transform);
113
+ const scale = axisScale(native.style.transform || getComputedStyle(native).transform, vertical);
100
114
  const samples = [
101
115
  [0, 1],
102
116
  [0.033, 0.85],
@@ -110,22 +124,24 @@ export const createTabBarReleaseAnimation = (effectElement, target, tap) => {
110
124
  [0.45, 0],
111
125
  ];
112
126
  const bar = target.parentElement;
113
- const box = bar.getBoundingClientRect();
114
- const current = effectElement.getBoundingClientRect();
115
- const targetBox = target.getBoundingClientRect();
127
+ const box = animationRect(bar, vertical);
128
+ const current = animationRect(effectElement, vertical);
129
+ const targetBox = animationRect(target, vertical);
116
130
  const center = box.left + box.width / 2;
117
- const x = center + (targetBox.left + targetBox.width / 2 - center) / (box.width / bar.offsetWidth) - current.width / 2;
131
+ const x = center + (targetBox.left + targetBox.width / 2 - center) / (box.width / mainSize(bar, vertical)) - current.width / 2;
118
132
  const y = box.top + box.height / 2 - current.height / 2;
133
+ const endScaleX = vertical ? target.offsetHeight / current.width : 1;
134
+ const endScaleY = vertical ? target.offsetWidth / current.height : 1;
119
135
  const elapsed = (tapElapsed ?? 0) / 1000;
120
136
  const duration = tapElapsed === undefined ? 450 : 800 - tapElapsed;
121
137
  let frames = samples.map(([time, remaining]) => ({
122
138
  offset: time / 0.45,
123
139
  x: x + (current.x - x) * remaining,
124
140
  y: y + (current.y - y) * remaining,
125
- sx: 1 + (scale.a - 1) * remaining,
126
- sy: 1 + (scale.d - 1) * remaining,
141
+ sx: endScaleX + (scale.a - endScaleX) * remaining,
142
+ sy: endScaleY + (scale.d - endScaleY) * remaining,
127
143
  }));
128
- if (tapElapsed !== undefined) {
144
+ if (tapElapsed !== undefined && !vertical) {
129
145
  const nextIndex = tabBarTapFrames.findIndex(([time]) => time > elapsed);
130
146
  const previous = tabBarTapFrames[Math.max(0, nextIndex - 1)];
131
147
  const next = tabBarTapFrames[nextIndex];
@@ -136,7 +152,7 @@ export const createTabBarReleaseAnimation = (effectElement, target, tap) => {
136
152
  ...tabBarTapFrames.slice(nextIndex).map(([time, position, width, height]) => ({
137
153
  offset: (time - elapsed) / (0.8 - elapsed),
138
154
  x: x + ((current.x - x) * (1 - position)) / Math.max(0.1, 1 - progress),
139
- y,
155
+ y: y + ((current.y - y) * (1 - position)) / Math.max(0.1, 1 - progress),
140
156
  sx: 1 + (width * (time >= 0.367 ? 1 + 0.75 * extraDistance : 1)) / current.width,
141
157
  sy: 1 + (height * (time >= 0.367 ? 1 + 0.5 * extraDistance : 1)) / current.height,
142
158
  })),
@@ -146,14 +162,14 @@ export const createTabBarReleaseAnimation = (effectElement, target, tap) => {
146
162
  .addElement(native)
147
163
  .duration(duration)
148
164
  .easing('linear')
149
- .keyframes(frames.map(({ offset, sx, sy }) => ({ offset, transform: `scale(${sx}, ${sy})` })));
165
+ .keyframes(frames.map(({ offset, sx, sy }) => ({ offset, transform: stretchTransform(sx, sy, vertical) })));
150
166
  const move = createAnimation()
151
167
  .addElement(effectElement)
152
168
  .duration(duration)
153
169
  .easing('linear')
154
170
  .keyframes(frames.map(({ offset, x, y }) => ({
155
171
  offset,
156
- transform: `translate3d(${x}px, ${y}px, 0)`,
172
+ transform: translate(x, y, vertical),
157
173
  })));
158
174
  const offset = 1 - 200 / duration;
159
175
  const selectedBackground = 'var(--ios27-tab-selected-background, rgba(var(--ios-theme-button-color-selected-rgb, var(--ios26-button-color-selected-rgb, var(--ios27-button-color-selected-default-rgb))), 0.095))';
@@ -174,8 +190,8 @@ export const createTabBarReleaseAnimation = (effectElement, target, tap) => {
174
190
  { offset: 1, backgroundColor: selectedBackground },
175
191
  ]);
176
192
  const release = createAnimation().duration(duration).addAnimation([stretch, move, glass, selected]);
177
- if (tapElapsed !== undefined) {
178
- const width = bar.offsetWidth;
193
+ if (tapElapsed !== undefined && !vertical) {
194
+ const width = mainSize(bar, vertical);
179
195
  const expansion = box.width - width;
180
196
  const peak = Math.max(8.65, expansion);
181
197
  const platter = createAnimation()
@@ -236,7 +252,7 @@ export const createPreMoveAnimation = (effectElement, tabSelectedElement, curren
236
252
  },
237
253
  ]);
238
254
  };
239
- export const createMoveAnimation = (effectElement, detail, tabSelectedElement, animationPosition) => {
255
+ export const createMoveAnimation = (effectElement, detail, tabSelectedElement, animationPosition, vertical = false) => {
240
256
  return createAnimation()
241
257
  .duration(500)
242
258
  .addElement(effectElement)
@@ -247,7 +263,7 @@ export const createMoveAnimation = (effectElement, detail, tabSelectedElement, a
247
263
  opacity: '1',
248
264
  transform: 'none',
249
265
  })
250
- .fromTo('transform', `translate3d(${animationPosition.minPositionX}px, ${animationPosition.positionY}px, 0)`, `translate3d(${animationPosition.maxPositionX}px, ${animationPosition.positionY}px, 0)`)
266
+ .fromTo('transform', translate(animationPosition.minPositionX, animationPosition.positionY, vertical), translate(animationPosition.maxPositionX, animationPosition.positionY, vertical))
251
267
  .progressStep(getStep(detail.currentX, animationPosition));
252
268
  };
253
269
  export const getMoveAnimationKeyframe = (type, scales) => {
@@ -1,3 +1,3 @@
1
1
  import { EffectScales, registeredEffect } from './interfaces';
2
- export declare const registerEffect: (targetElement: HTMLElement, effectTagName: string, selectedClassName: string, scales: EffectScales) => registeredEffect | undefined;
2
+ export declare const registerEffect: (targetElement: HTMLElement, effectTagName: string, selectedClassName: string, scales: EffectScales, vertical?: boolean) => registeredEffect | undefined;
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/sheets-of-glass/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,YAAY,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAkBjF,eAAO,MAAM,cAAc,GACzB,eAAe,WAAW,EAC1B,eAAe,MAAM,EACrB,mBAAmB,MAAM,EACzB,QAAQ,YAAY,KACnB,gBAAgB,GAAG,SAqVrB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/sheets-of-glass/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,YAAY,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAmBjF,eAAO,MAAM,cAAc,GACzB,eAAe,WAAW,EAC1B,eAAe,MAAM,EACrB,mBAAmB,MAAM,EACzB,QAAQ,YAAY,EACpB,kBAAgB,KACf,gBAAgB,GAAG,SAqZrB,CAAC"}
@@ -1,10 +1,10 @@
1
1
  import { createGesture } from '@ionic/core';
2
2
  import { changeSelectedElement, cloneElement, getStep } from '../utils';
3
3
  import { isNativeUIShell } from '../native-integration';
4
- import { createMoveAnimation, createPreMoveAnimation, createTabBarDragAnimation, createTabBarPressAnimation, createTabBarReleaseAnimation, getMoveAnimationKeyframe, getScaleAnimation, } from './animations';
4
+ import { animationRect, createMoveAnimation, createPreMoveAnimation, createTabBarDragAnimation, createTabBarPressAnimation, createTabBarReleaseAnimation, getMoveAnimationKeyframe, getScaleAnimation, } from './animations';
5
5
  const GESTURE_NAME = 'ios27-enable-gesture';
6
6
  const ANIMATED_NAME = 'ios27-animated';
7
- export const registerEffect = (targetElement, effectTagName, selectedClassName, scales) => {
7
+ export const registerEffect = (targetElement, effectTagName, selectedClassName, scales, vertical = false) => {
8
8
  if (!targetElement.classList.contains('ios')) {
9
9
  return undefined;
10
10
  }
@@ -24,6 +24,8 @@ export const registerEffect = (targetElement, effectTagName, selectedClassName,
24
24
  let destroyed = false;
25
25
  const originalTabBarScale = targetElement.style.getPropertyValue('--ios27-tab-bar-scale');
26
26
  const effectElement = cloneElement(effectTagName, false);
27
+ if (vertical)
28
+ effectElement.classList.add('ios27-vertical-tab-effect');
27
29
  const stopTabPress = () => {
28
30
  if (!tabPressAnimation)
29
31
  return;
@@ -98,17 +100,36 @@ export const registerEffect = (targetElement, effectTagName, selectedClassName,
98
100
  document.addEventListener('pointercancel', onPointerCancel);
99
101
  };
100
102
  targetElement.addEventListener('pointerdown', onPointerDown);
103
+ const onClick = (event) => {
104
+ if (vertical && tabDragging && event.isTrusted && event.detail > 0) {
105
+ event.preventDefault();
106
+ event.stopImmediatePropagation();
107
+ }
108
+ };
109
+ targetElement.addEventListener('click', onClick, true);
110
+ const axisDetail = (detail) => vertical
111
+ ? {
112
+ ...detail,
113
+ currentX: detail.currentY,
114
+ currentY: detail.currentX,
115
+ startX: detail.startY,
116
+ startY: detail.startX,
117
+ velocityX: detail.velocityY,
118
+ velocityY: detail.velocityX,
119
+ }
120
+ : detail;
101
121
  const createAnimationGesture = () => {
102
122
  targetElement.classList.add(GESTURE_NAME);
103
123
  gesture = createGesture({
104
124
  el: targetElement,
105
125
  threshold: 0,
126
+ direction: vertical ? 'y' : 'x',
106
127
  gestureName: `${GESTURE_NAME}_${effectTagName}_${crypto.randomUUID()}`,
107
- onStart: (event) => onStartGesture(event),
108
- onMove: (event) => onMoveGesture(event),
128
+ onStart: (event) => onStartGesture(axisDetail(event)),
129
+ onMove: (event) => onMoveGesture(axisDetail(event)),
109
130
  onEnd: (detail) => {
110
131
  if (effectTagName === 'ion-tab-button' && tabDragging)
111
- onMoveGesture(detail);
132
+ onMoveGesture(axisDetail(detail));
112
133
  onEndGesture().then();
113
134
  },
114
135
  });
@@ -135,25 +156,31 @@ export const registerEffect = (targetElement, effectTagName, selectedClassName,
135
156
  return false;
136
157
  currentTouchedElement = detail.event.target.closest(effectTagName) || undefined;
137
158
  const tabSelectedElement = targetElement.querySelector(`${effectTagName}.${selectedClassName}`);
138
- if (currentTouchedElement === undefined || tabSelectedElement === null) {
159
+ if (currentTouchedElement === undefined ||
160
+ currentTouchedElement.parentElement !== targetElement ||
161
+ currentTouchedElement.disabled ||
162
+ tabSelectedElement === null) {
163
+ currentTouchedElement = undefined;
139
164
  return false;
140
165
  }
141
166
  selectedElementBeforeGesture = tabSelectedElement;
142
167
  if (effectTagName === 'ion-tab-button') {
143
- targetElement.style.setProperty('--ios27-tab-bar-scale', String(1 + 14.14 / targetElement.offsetWidth));
168
+ targetElement.style.setProperty('--ios27-tab-bar-scale', String(1 + 14.14 / (vertical ? targetElement.offsetHeight : targetElement.offsetWidth)));
144
169
  }
170
+ if (vertical)
171
+ targetElement.classList.add(ANIMATED_NAME);
145
172
  animationPosition = {
146
- minPositionX: targetElement.getBoundingClientRect().left,
147
- maxPositionX: targetElement.getBoundingClientRect().right - tabSelectedElement.clientWidth,
148
- width: tabSelectedElement.clientWidth,
149
- positionY: tabSelectedElement.getBoundingClientRect().top,
173
+ minPositionX: animationRect(targetElement, vertical).left,
174
+ maxPositionX: animationRect(targetElement, vertical).right - (vertical ? tabSelectedElement.clientHeight : tabSelectedElement.clientWidth),
175
+ width: vertical ? tabSelectedElement.clientHeight : tabSelectedElement.clientWidth,
176
+ positionY: animationRect(tabSelectedElement, vertical).top,
150
177
  };
151
178
  targetElement.classList.add(ANIMATED_NAME);
152
179
  changeSelectedElement(targetElement, currentTouchedElement, effectTagName, selectedClassName);
153
180
  if (effectTagName === 'ion-tab-button') {
154
- moveAnimation = createMoveAnimation(effectElement, detail, tabSelectedElement, animationPosition);
155
- moveAnimation.progressStart(true, getStep(tabSelectedElement.getBoundingClientRect().left + tabSelectedElement.clientWidth / 2, animationPosition));
156
- tabPressAnimation = createTabBarPressAnimation(effectElement, tabSelectedElement, currentTouchedElement, targetElement);
181
+ moveAnimation = createMoveAnimation(effectElement, detail, tabSelectedElement, animationPosition, vertical);
182
+ moveAnimation.progressStart(true, getStep(animationRect(tabSelectedElement, vertical).left + animationRect(tabSelectedElement, vertical).width / 2, animationPosition));
183
+ tabPressAnimation = createTabBarPressAnimation(effectElement, tabSelectedElement, currentTouchedElement, targetElement, vertical);
157
184
  tabPressStartedAt = performance.now();
158
185
  void tabPressAnimation.play();
159
186
  return true;
@@ -187,7 +214,7 @@ export const registerEffect = (targetElement, effectTagName, selectedClassName,
187
214
  return;
188
215
  tabDragging = true;
189
216
  stopTabPress();
190
- tabPressAnimation = createTabBarDragAnimation(effectElement, targetElement, detail.velocityX);
217
+ tabPressAnimation = createTabBarDragAnimation(effectElement, targetElement, detail.velocityX, vertical);
191
218
  void tabPressAnimation.play();
192
219
  }
193
220
  else if (scaleAnimationPromise === undefined) {
@@ -211,10 +238,22 @@ export const registerEffect = (targetElement, effectTagName, selectedClassName,
211
238
  }
212
239
  }
213
240
  const currentX = detail.currentX;
214
- const previousY = targetElement.getBoundingClientRect().top + targetElement.getBoundingClientRect().height / 2;
215
- const nextEl = targetElement.getRootNode().elementFromPoint(currentX, previousY);
216
- const latestTouchedElement = nextEl?.closest(effectTagName) || undefined;
217
- if (latestTouchedElement && currentTouchedElement !== latestTouchedElement) {
241
+ const previousY = animationRect(targetElement, vertical).top + animationRect(targetElement, vertical).height / 2;
242
+ const nextEl = targetElement.getRootNode().elementFromPoint(vertical ? previousY : currentX, vertical ? currentX : previousY);
243
+ let latestTouchedElement = nextEl?.closest(effectTagName) || undefined;
244
+ if (vertical && effectTagName === 'ion-tab-button') {
245
+ const bar = targetElement.getBoundingClientRect();
246
+ const tabs = Array.from(targetElement.querySelectorAll(effectTagName)).filter((tab) => tab.offsetWidth > 0);
247
+ const distance = (tab) => Math.abs(currentX - (bar.top + tab.offsetTop + tab.offsetHeight / 2));
248
+ latestTouchedElement =
249
+ currentX >= bar.top && currentX <= bar.bottom
250
+ ? tabs.reduce((closest, tab) => (!closest || distance(tab) < distance(closest) ? tab : closest), undefined)
251
+ : undefined;
252
+ }
253
+ if (latestTouchedElement &&
254
+ latestTouchedElement.parentElement === targetElement &&
255
+ !latestTouchedElement.disabled &&
256
+ currentTouchedElement !== latestTouchedElement) {
218
257
  currentTouchedElement = latestTouchedElement;
219
258
  changeSelectedElement(targetElement, currentTouchedElement, effectTagName, selectedClassName);
220
259
  }
@@ -263,12 +302,16 @@ export const registerEffect = (targetElement, effectTagName, selectedClassName,
263
302
  moveAnimation.destroy();
264
303
  moveAnimation = undefined;
265
304
  const tabs = Array.from(targetElement.querySelectorAll(effectTagName)).filter((tab) => tab.offsetWidth > 0);
305
+ if (vertical) {
306
+ currentTouchedElement.classList.remove('ion-activated');
307
+ targetElement.classList.remove(ANIMATED_NAME);
308
+ }
266
309
  const releasing = createTabBarReleaseAnimation(effectElement, currentTouchedElement, tapElapsed !== undefined && tapElapsed < 200
267
310
  ? {
268
311
  elapsed: tapElapsed,
269
312
  distance: Math.abs(tabs.indexOf(currentTouchedElement) - tabs.indexOf(selectedElementBeforeGesture)),
270
313
  }
271
- : undefined);
314
+ : undefined, vertical);
272
315
  currentTouchedElement.classList.remove('ion-activated');
273
316
  releaseAnimation = releasing;
274
317
  await releasing.play();
@@ -297,6 +340,7 @@ export const registerEffect = (targetElement, effectTagName, selectedClassName,
297
340
  destroyed = true;
298
341
  cancelActiveGesture();
299
342
  targetElement.removeEventListener('pointerdown', onPointerDown);
343
+ targetElement.removeEventListener('click', onClick, true);
300
344
  targetElement.removeEventListener('nativeUIShellChange', nativeChanged);
301
345
  if (gesture) {
302
346
  gesture.destroy();
@@ -1 +1 @@
1
- {"version":3,"file":"ios.transition.d.ts","sourceRoot":"","sources":["../../src/transition/ios.transition.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgC,MAAM,EAAE,MAAM,2BAA2B,CAAC;AACjF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAIjD,OAAO,EAAE,MAAM,EAAE,CAAC;AAElB,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;CAChB;AAUD,eAAO,MAAM,SAAS,GAAI,QAAQ,OAAO,CAAC,mBAAmB,CAAC,KAAG,IAEhE,CAAC;AAEF,eAAO,MAAM,sBAAsB,EAAE,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,iBAAiB,KAAK,SACnB,CAAC"}
1
+ {"version":3,"file":"ios.transition.d.ts","sourceRoot":"","sources":["../../src/transition/ios.transition.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgC,MAAM,EAAE,MAAM,2BAA2B,CAAC;AACjF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAIjD,OAAO,EAAE,MAAM,EAAE,CAAC;AAElB,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;CAChB;AASD,eAAO,MAAM,SAAS,GAAI,QAAQ,OAAO,CAAC,mBAAmB,CAAC,KAAG,IAEhE,CAAC;AAEF,eAAO,MAAM,sBAAsB,EAAE,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,iBAAiB,KAAK,SAErF,CAAC"}
@@ -1,15 +1,14 @@
1
1
  import { createIosTransitionAnimation, shadow } from '@rdlabo/ionic-theme-utils';
2
2
  import { getIonPageElement } from './index';
3
- import { connectNativeUIShellTransition } from '../native-integration';
3
+ import { withNativeUIShellTransition } from '../native-integration/transition';
4
4
  export { shadow };
5
5
  const transitionConfig = {
6
6
  offLeftPercent: 30,
7
7
  getIonPageElement,
8
- connectNativeUIShellTransition,
9
8
  shouldAnimateFixedBackButton: (navEl) => !navEl.closest('ion-app.ios-theme-vertical-bars'),
10
9
  radius: 0,
11
10
  };
12
11
  export const setConfig = (config) => {
13
12
  Object.assign(transitionConfig, config);
14
13
  };
15
- export const iosTransitionAnimation = createIosTransitionAnimation(transitionConfig);
14
+ export const iosTransitionAnimation = withNativeUIShellTransition(createIosTransitionAnimation(transitionConfig));
@@ -1,4 +1,4 @@
1
1
  export { enableVerticalControlArea, setVerticalControlAreaPlacement, IonicNativeUIShell } from './native';
2
- export { HingeStatus } from './native';
3
- export type { DeviceLayout, NativeUIShellStatus, NativeUIShellSuspension, VerticalBarEdge, VerticalControlAreaHandle } from './native';
2
+ export { withNativeUIShellTransition } from './native-integration/transition';
3
+ export type { NativeUIShellStatus, NativeUIShellSuspension, VerticalBarEdge, VerticalControlAreaHandle } from './native';
4
4
  //# sourceMappingURL=vertical-bars.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"vertical-bars.d.ts","sourceRoot":"","sources":["../src/vertical-bars.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,+BAA+B,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAC1G,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,YAAY,EAAE,YAAY,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,eAAe,EAAE,yBAAyB,EAAE,MAAM,UAAU,CAAC"}
1
+ {"version":3,"file":"vertical-bars.d.ts","sourceRoot":"","sources":["../src/vertical-bars.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,+BAA+B,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAC1G,OAAO,EAAE,2BAA2B,EAAE,MAAM,iCAAiC,CAAC;AAC9E,YAAY,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,eAAe,EAAE,yBAAyB,EAAE,MAAM,UAAU,CAAC"}
@@ -1,2 +1,2 @@
1
1
  export { enableVerticalControlArea, setVerticalControlAreaPlacement, IonicNativeUIShell } from './native';
2
- export { HingeStatus } from './native';
2
+ export { withNativeUIShellTransition } from './native-integration/transition';
@@ -0,0 +1,171 @@
1
+ ---
2
+ title: iPhone Duo with your existing theme (experimental)
3
+ ---
4
+
5
+ # iPhone Duo with your existing theme (experimental)
6
+
7
+ Add a vertical navigation area to your Ionic app while keeping its existing theme. Tabs and supported toolbar actions move to the side of the screen; your content and horizontal controls keep their current appearance. Both Ionic `ios` and `md` modes are supported.
8
+
9
+ **Try it in Chrome first.** You can preview the layout with Web controls before setting up an iPhone Duo or an iOS build. On supported Capacitor iOS, the same Ionic markup supplies native SwiftUI controls in the system rail.
10
+
11
+ Available in `1.2.0-0` as an **experimental** feature. APIs and supported behavior may change.
12
+
13
+ ## Try it in your existing Ionic app
14
+
15
+ ### 1. Install and load the standalone stylesheet
16
+
17
+ This guide assumes an existing Ionic app with Ionic `>=8.8.1 <10` and Capacitor Core `>=8 <9`. Keep your existing Capacitor 8 installation. If your app uses another Capacitor major, migrate its Core, CLI, and platform packages together before following this guide. For a Web-only app without Capacitor, also install `@capacitor/core@^8`; the JavaScript entry point needs it even in Chrome.
18
+
19
+ ```bash
20
+ npm install @rdlabo/ionic-theme-ios27@1.2.0-1
21
+ ```
22
+
23
+ Keep your existing theme imports. Add this to your global Sass file:
24
+
25
+ ```scss
26
+ @use '@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css';
27
+ ```
28
+
29
+ The standalone JavaScript entry point needs `@capacitor/core` even in Chrome. The iOS 27 theme stylesheets are not required.
30
+
31
+ ### 2. Opt your app into the side layout
32
+
33
+ Add the class to your existing app root and keep the content inside it:
34
+
35
+ ```html
36
+ <ion-app class="ios-theme-vertical-bars">
37
+ <!-- Keep your existing pages, tabs, and toolbar controls here. -->
38
+ </ion-app>
39
+ ```
40
+
41
+ The preview reserves `80px` on the physical right. To preview the left side, also add `ios-theme-vertical-bars-left`.
42
+
43
+ ### 3. Connect your navigation animation
44
+
45
+ Configure `navAnimation` before Ionic initializes. Starting the rail runtime does not register this option. The adapter waits for native control retirement and coordinates swipe progress and cancellation while keeping your existing animation.
46
+
47
+ #### Keep Ionic's default animation
48
+
49
+ If you have not configured `navAnimation`, wrap Ionic's standard builders. Select the builder from Ionic's transition `mode` so both `ios` and `md` keep their usual animation:
50
+
51
+ ```ts
52
+ import { iosTransitionAnimation, mdTransitionAnimation, type AnimationBuilder } from '@ionic/core';
53
+ import { withNativeUIShellTransition } from '@rdlabo/ionic-theme-ios27/vertical-bars';
54
+
55
+ const defaultTransition: AnimationBuilder = (baseEl, opts) =>
56
+ (opts.mode === 'ios' ? iosTransitionAnimation : mdTransitionAnimation)(baseEl, opts);
57
+
58
+ const ionicConfig = {
59
+ navAnimation: withNativeUIShellTransition(defaultTransition),
60
+ };
61
+ ```
62
+
63
+ Merge this option into your existing Ionic configuration before initialization: pass it to Angular's `provideIonicAngular()`, React's `setupIonicReact()`, or Vue's `IonicVue` plugin options. Keep your existing theme stylesheet imports. No iOS 27 theme stylesheet is required.
64
+
65
+ #### Use this package's iOS animation
66
+
67
+ If you already use the iOS 27 transition, keep this configuration. It includes the native adapter and excludes the horizontal back-button effect in vertical layouts; no additional wrapper is needed. Importing this JavaScript entry point does not load the theme stylesheets.
68
+
69
+ ```ts
70
+ import { iosTransitionAnimation } from '@rdlabo/ionic-theme-ios27';
71
+
72
+ const ionicConfig = {
73
+ navAnimation: iosTransitionAnimation,
74
+ };
75
+ ```
76
+
77
+ Apply this option to your existing iOS-mode configuration and keep your MD configuration.
78
+
79
+ #### Keep your custom animation
80
+
81
+ If your app uses another builder for `navAnimation`, wrap it:
82
+
83
+ ```ts
84
+ import type { AnimationBuilder } from '@ionic/core';
85
+ import { withNativeUIShellTransition } from '@rdlabo/ionic-theme-ios27/vertical-bars';
86
+
87
+ // Pass the animation builder your app already uses.
88
+ const configureNavigation = (existingTransition: AnimationBuilder) => ({
89
+ navAnimation: withNativeUIShellTransition(existingTransition),
90
+ });
91
+ ```
92
+
93
+ The adapter returns the original `Animation`, preserving its effects, duration, and easing. Use it only for navigation, not modal or popover animations. The builder must return a fresh `Animation` for each navigation; Ionic destroys it after the transition. Keep lifecycle events for control registration and transitions without animation.
94
+
95
+ The adapter keeps the builder's animation targets, including any horizontal back-button effect. If you need the iOS 27 transition with that effect excluded in vertical layouts, use `iosTransitionAnimation` from `@rdlabo/ionic-theme-ios27` as your `navAnimation` instead. It already includes the adapter, so no wrapper is needed.
96
+
97
+ `withNativeUIShellTransition()` is available in `1.2.0-1` and later.
98
+
99
+ ### 4. Start the controls after the app root is mounted
100
+
101
+ Call this once from your application startup after `ion-app` exists in the DOM:
102
+
103
+ ```ts
104
+ import { enableVerticalControlArea } from '@rdlabo/ionic-theme-ios27/vertical-bars';
105
+
106
+ const rail = await enableVerticalControlArea();
107
+ ```
108
+
109
+ **What you should see:** your existing tab bar moves to the side, and fixed-toolbar buttons with an `ion-icon` or SVG using `slot="icon-only"` appear there too. Content keeps its existing theme and leaves room for the controls. The Web tab rail displays icons; pressing and dragging reveals tab labels.
110
+
111
+ Use your existing Ionic click handlers, routing, and form associations. All button fills (`default`, `clear`, `solid`, and `outline`) use the same `icon-only` rule, including submit buttons. Actions without that slot remain horizontal. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep an action in the horizontal toolbar.
112
+
113
+ When the application owner is disposed, call `await rail.destroy()` to restore the original controls and release the runtime. If you already use `enableNativeUIShell()`, keep that runtime and follow the [shared placement guide](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo#project-controls-into-the-rail).
114
+
115
+ ### If the preview does not appear
116
+
117
+ | What you see | What to check |
118
+ | --- | --- |
119
+ | No space at the side | Load `vertical-bars.css` and put the class on `ion-app`. |
120
+ | Space appears, but controls stay horizontal | Start `enableVerticalControlArea()` after mounting the app root. Use existing tabs or `slot="icon-only"` actions in a fixed header/footer toolbar. |
121
+ | One action stays horizontal | Check for `slot="icon-only"` on the icon and a fixed toolbar outside scrolling content. Explicitly excluded controls and controls in centered modals stay horizontal; `fill` and `type="submit"` do not prevent movement. See [control requirements](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo#toolbar-actions). |
122
+
123
+ ## Connect an iPhone Duo
124
+
125
+ Install [`@erkamyaman/capacitor-foldable`](https://github.com/erkamyaman/capacitor-foldable) for device state:
126
+
127
+ ```bash
128
+ npm install @erkamyaman/capacitor-foldable
129
+ npx cap sync ios
130
+ ```
131
+
132
+ Use Capacitor 8.5 or later and build with Xcode 27.1 or newer for actual rail placement and hinge posture on iOS 27.1. Native UI Shell uses Swift Package Manager; existing CocoaPods apps can follow [Native UI Shell setup](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/native-ui-shell#enable-the-shell). Keep this package's `vertical-bars.css`; the device plugin's `ionic-tabs.css` is not needed with our rail projection.
133
+
134
+ Replace the browser-only startup above with this after `ion-app` is mounted:
135
+
136
+ ```ts
137
+ import { Capacitor, type PluginListenerHandle } from '@capacitor/core';
138
+ import { enableVerticalControlArea } from '@rdlabo/ionic-theme-ios27/vertical-bars';
139
+ import { Foldable } from '@erkamyaman/capacitor-foldable';
140
+
141
+ const rail = await enableVerticalControlArea();
142
+ let layoutListener: PluginListenerHandle | undefined;
143
+
144
+ if (Capacitor.getPlatform() === 'ios') {
145
+ layoutListener = await Foldable.addListener('barPlacementChange', ({ verticalBarEdge, inset }) =>
146
+ rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset }),
147
+ );
148
+ const { verticalBarEdge, inset } = await Foldable.getBarPlacement();
149
+ rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset });
150
+ }
151
+
152
+ // Call when the application owner is disposed.
153
+ const stopVerticalArea = async () => {
154
+ await layoutListener?.remove();
155
+ await rail.destroy();
156
+ };
157
+ ```
158
+
159
+ `setPlacement()` resolves the logical edge through the document direction. Pass Foldable’s measured `inset` with the reported edge so the theme reserves the actual bar width and follows width changes. An inset of `0` clears the explicit width; manually requested rails then use the theme’s CSS safe-area rules. A `null` edge restores the ordinary layout. Devices without a reported rail return `null`, so this example restores the ordinary layout there. On iOS 27.1 or later, Foldable can infer Duo bar placement from safe-area insets when the app is built with an older SDK. To deliberately request a rail when the plugin reports no edge, have your application choose a fixed edge with `rail.setPlacement('trailing')` instead of applying that null placement. This simulates the layout; it does not provide a real system rail or hinge measurements.
160
+
161
+ On supported iOS, controls in the rail use the system SwiftUI appearance; your custom Web styling still applies to ordinary content and horizontal controls. Web and Android use Web clones.
162
+
163
+ ## Use hinge posture without projecting controls
164
+
165
+ If your existing theme needs only a posture-driven split pane or a layout switch, do not start a projection runtime or add `.ios-theme-vertical-bars`. Use `Foldable.getFoldState()` and `foldStateChange` directly, removing the listener when finished. There is no separate start/stop monitoring call.
166
+
167
+ See [Read the device layout](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo#read-the-device-layout) for the subscription example, null values, and monitoring lifetime. See [Adapt the split pane](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo#adapt-the-split-pane) for the opt-in width rules and half-open state.
168
+
169
+ ## Shared layout rules and API
170
+
171
+ Safe-area handling, overlays, RTL, control eligibility, Web simulation, and the handle API are documented in [iPhone Duo support](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo). Those rules apply to this standalone setup too.