@rdlabo/ionic-theme-ios27 1.2.0-1 → 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 (67) hide show
  1. package/README.md +9 -0
  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.map +1 -1
  6. package/dist/index.js +31 -12
  7. package/dist/native/components/index.d.ts.map +1 -1
  8. package/dist/native/components/index.js +4 -1
  9. package/dist/native/components/ion-button.d.ts.map +1 -1
  10. package/dist/native/components/ion-button.js +3 -3
  11. package/dist/native/components/ion-buttons.d.ts.map +1 -1
  12. package/dist/native/components/ion-buttons.js +3 -2
  13. package/dist/native/definitions.d.ts +8 -15
  14. package/dist/native/definitions.d.ts.map +1 -1
  15. package/dist/native/definitions.js +1 -6
  16. package/dist/native/index.d.ts +1 -2
  17. package/dist/native/index.d.ts.map +1 -1
  18. package/dist/native/index.js +64 -28
  19. package/dist/native/prehide.d.ts.map +1 -1
  20. package/dist/native/prehide.js +6 -4
  21. package/dist/native/runtime.d.ts.map +1 -1
  22. package/dist/native/runtime.js +27 -12
  23. package/dist/native/shared/candidate.d.ts.map +1 -1
  24. package/dist/native/shared/candidate.js +8 -0
  25. package/dist/native/shared/dom.d.ts.map +1 -1
  26. package/dist/native/shared/dom.js +11 -10
  27. package/dist/native/shared/modal.d.ts +8 -0
  28. package/dist/native/shared/modal.d.ts.map +1 -0
  29. package/dist/native/shared/modal.js +143 -0
  30. package/dist/native/vertical-bars-web.d.ts.map +1 -1
  31. package/dist/native/vertical-bars-web.js +11 -3
  32. package/dist/sheets-of-glass/animations.d.ts +13 -4
  33. package/dist/sheets-of-glass/animations.d.ts.map +1 -1
  34. package/dist/sheets-of-glass/animations.js +56 -40
  35. package/dist/sheets-of-glass/index.d.ts +1 -1
  36. package/dist/sheets-of-glass/index.d.ts.map +1 -1
  37. package/dist/sheets-of-glass/index.js +64 -20
  38. package/dist/vertical-bars.d.ts +1 -2
  39. package/dist/vertical-bars.d.ts.map +1 -1
  40. package/dist/vertical-bars.js +0 -1
  41. package/docs/iphone-duo-with-original-theme.md +37 -15
  42. package/docs/iphone-duo.md +90 -47
  43. package/docs/migration.md +3 -3
  44. package/docs/native-ui-shell.md +7 -5
  45. package/docs/special-markup.md +0 -13
  46. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +23 -4
  47. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +15 -171
  48. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +5 -0
  49. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +30 -0
  50. package/package.json +1 -1
  51. package/src/index.ts +40 -9
  52. package/src/native/components/index.ts +6 -1
  53. package/src/native/components/ion-button.ts +4 -3
  54. package/src/native/components/ion-buttons.ts +4 -2
  55. package/src/native/definitions.ts +11 -19
  56. package/src/native/index.ts +67 -30
  57. package/src/native/prehide.ts +7 -4
  58. package/src/native/runtime.ts +28 -11
  59. package/src/native/shared/candidate.ts +10 -0
  60. package/src/native/shared/dom.ts +11 -10
  61. package/src/native/shared/modal.ts +157 -0
  62. package/src/native/vertical-bars-web.ts +12 -3
  63. package/src/sheets-of-glass/animations.ts +66 -41
  64. package/src/sheets-of-glass/index.ts +82 -16
  65. package/src/styles/components/ion-tabs.scss +7 -4
  66. package/src/styles/vertical-bars.scss +102 -35
  67. package/src/vertical-bars.ts +1 -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,5 +1,4 @@
1
1
  export { enableVerticalControlArea, setVerticalControlAreaPlacement, IonicNativeUIShell } from './native';
2
- export { HingeStatus } from './native';
3
2
  export { withNativeUIShellTransition } from './native-integration/transition';
4
- export type { DeviceLayout, NativeUIShellStatus, NativeUIShellSuspension, VerticalBarEdge, VerticalControlAreaHandle } from './native';
3
+ export type { NativeUIShellStatus, NativeUIShellSuspension, VerticalBarEdge, VerticalControlAreaHandle } from './native';
5
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,OAAO,EAAE,2BAA2B,EAAE,MAAM,iCAAiC,CAAC;AAC9E,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,3 +1,2 @@
1
1
  export { enableVerticalControlArea, setVerticalControlAreaPlacement, IonicNativeUIShell } from './native';
2
- export { HingeStatus } from './native';
3
2
  export { withNativeUIShellTransition } from './native-integration/transition';
@@ -17,7 +17,7 @@ Available in `1.2.0-0` as an **experimental** feature. APIs and supported behavi
17
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
18
 
19
19
  ```bash
20
- npm install @rdlabo/ionic-theme-ios27@1.2.0-0
20
+ npm install @rdlabo/ionic-theme-ios27@1.2.0-1
21
21
  ```
22
22
 
23
23
  Keep your existing theme imports. Add this to your global Sass file:
@@ -62,9 +62,23 @@ const ionicConfig = {
62
62
 
63
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
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
+
65
79
  #### Keep your custom animation
66
80
 
67
- If your app already configures `navAnimation`, wrap that builder instead:
81
+ If your app uses another builder for `navAnimation`, wrap it:
68
82
 
69
83
  ```ts
70
84
  import type { AnimationBuilder } from '@ionic/core';
@@ -80,7 +94,7 @@ The adapter returns the original `Animation`, preserving its effects, duration,
80
94
 
81
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.
82
96
 
83
- The adapter is available after `1.2.0-0`. With `1.2.0-0`, use the package's `iosTransitionAnimation` for iOS mode and keep your existing MD configuration.
97
+ `withNativeUIShellTransition()` is available in `1.2.0-1` and later.
84
98
 
85
99
  ### 4. Start the controls after the app root is mounted
86
100
 
@@ -92,9 +106,9 @@ import { enableVerticalControlArea } from '@rdlabo/ionic-theme-ios27/vertical-ba
92
106
  const rail = await enableVerticalControlArea();
93
107
  ```
94
108
 
95
- **What you should see:** your existing tab bar moves to the side, and supported icon-based fixed-toolbar actions 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.
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.
96
110
 
97
- Use your existing Ionic click handlers and routing. Text-only toolbar actions remain horizontal. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep an action in the horizontal toolbar.
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.
98
112
 
99
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).
100
114
 
@@ -103,28 +117,36 @@ When the application owner is disposed, call `await rail.destroy()` to restore t
103
117
  | What you see | What to check |
104
118
  | --- | --- |
105
119
  | No space at the side | Load `vertical-bars.css` and put the class on `ion-app`. |
106
- | Space appears, but controls stay horizontal | Start `enableVerticalControlArea()` after mounting the app root. Use existing tabs or supported icon-based actions in a fixed toolbar. |
107
- | One action stays horizontal | Text-only actions, custom fills, and explicitly excluded controls keep their original presentation. See [control requirements](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo#project-controls-into-the-rail). |
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). |
108
122
 
109
123
  ## Connect an iPhone Duo
110
124
 
111
- For Capacitor iOS, run `npx cap sync ios`. Build with Xcode 27.1 or newer and link against the iOS 27.1 SDK or later to receive the actual rail edge, safe-area inset, and hinge posture. The plugin 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).
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.
112
133
 
113
134
  Replace the browser-only startup above with this after `ion-app` is mounted:
114
135
 
115
136
  ```ts
116
137
  import { Capacitor, type PluginListenerHandle } from '@capacitor/core';
117
- import { enableVerticalControlArea, IonicNativeUIShell } from '@rdlabo/ionic-theme-ios27/vertical-bars';
138
+ import { enableVerticalControlArea } from '@rdlabo/ionic-theme-ios27/vertical-bars';
139
+ import { Foldable } from '@erkamyaman/capacitor-foldable';
118
140
 
119
141
  const rail = await enableVerticalControlArea();
120
142
  let layoutListener: PluginListenerHandle | undefined;
121
143
 
122
144
  if (Capacitor.getPlatform() === 'ios') {
123
- // The runtime already monitors device layout; only subscribe.
124
- layoutListener = await IonicNativeUIShell.addListener('deviceLayoutChange', ({ placement }) =>
125
- rail.setPlacement(placement),
145
+ layoutListener = await Foldable.addListener('barPlacementChange', ({ verticalBarEdge, inset }) =>
146
+ rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset }),
126
147
  );
127
- rail.setPlacement((await IonicNativeUIShell.getDeviceLayout()).placement);
148
+ const { verticalBarEdge, inset } = await Foldable.getBarPlacement();
149
+ rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset });
128
150
  }
129
151
 
130
152
  // Call when the application owner is disposed.
@@ -134,13 +156,13 @@ const stopVerticalArea = async () => {
134
156
  };
135
157
  ```
136
158
 
137
- `setPlacement()` applies the measured inset and resolves the logical edge through the document direction. A `null` edge restores the ordinary layout. Devices without a rail and apps built with older SDKs report `null`, so this example restores the ordinary layout there. To deliberately preview a DOM rail on such an iOS build, 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.
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.
138
160
 
139
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.
140
162
 
141
163
  ## Use hinge posture without projecting controls
142
164
 
143
- 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 `getDeviceLayout()` and `deviceLayoutChange` directly, pairing `startDeviceLayoutMonitoring()` with `stopDeviceLayoutMonitoring()` and removing the listener when finished.
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.
144
166
 
145
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.
146
168