@rdlabo/ionic-theme-ios27 1.2.0-1 → 1.2.0-3

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 (75) 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/components/searchable-tabs.d.ts.map +1 -1
  14. package/dist/native/components/searchable-tabs.js +6 -7
  15. package/dist/native/definitions.d.ts +8 -15
  16. package/dist/native/definitions.d.ts.map +1 -1
  17. package/dist/native/definitions.js +1 -6
  18. package/dist/native/index.d.ts +1 -2
  19. package/dist/native/index.d.ts.map +1 -1
  20. package/dist/native/index.js +64 -28
  21. package/dist/native/prehide.d.ts.map +1 -1
  22. package/dist/native/prehide.js +6 -4
  23. package/dist/native/runtime.d.ts.map +1 -1
  24. package/dist/native/runtime.js +40 -19
  25. package/dist/native/shared/candidate.d.ts.map +1 -1
  26. package/dist/native/shared/candidate.js +8 -0
  27. package/dist/native/shared/dom.d.ts.map +1 -1
  28. package/dist/native/shared/dom.js +11 -10
  29. package/dist/native/shared/modal.d.ts +8 -0
  30. package/dist/native/shared/modal.d.ts.map +1 -0
  31. package/dist/native/shared/modal.js +143 -0
  32. package/dist/native/vertical-bars-web.d.ts.map +1 -1
  33. package/dist/native/vertical-bars-web.js +11 -3
  34. package/dist/sheets-of-glass/animations.d.ts +13 -4
  35. package/dist/sheets-of-glass/animations.d.ts.map +1 -1
  36. package/dist/sheets-of-glass/animations.js +56 -40
  37. package/dist/sheets-of-glass/index.d.ts +1 -1
  38. package/dist/sheets-of-glass/index.d.ts.map +1 -1
  39. package/dist/sheets-of-glass/index.js +64 -20
  40. package/dist/tab-bar-searchable/index.d.ts.map +1 -1
  41. package/dist/tab-bar-searchable/index.js +39 -11
  42. package/dist/vertical-bars.d.ts +1 -2
  43. package/dist/vertical-bars.d.ts.map +1 -1
  44. package/dist/vertical-bars.js +0 -1
  45. package/docs/iphone-duo-with-original-theme.md +37 -15
  46. package/docs/iphone-duo.md +90 -47
  47. package/docs/migration.md +3 -3
  48. package/docs/native-ui-shell.md +12 -8
  49. package/docs/special-markup.md +0 -13
  50. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +1 -1
  51. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +50 -6
  52. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalSearch.swift +118 -0
  53. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +32 -179
  54. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +5 -0
  55. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +67 -0
  56. package/package.json +1 -1
  57. package/src/index.ts +40 -9
  58. package/src/native/components/index.ts +6 -1
  59. package/src/native/components/ion-button.ts +4 -3
  60. package/src/native/components/ion-buttons.ts +4 -2
  61. package/src/native/components/searchable-tabs.ts +8 -11
  62. package/src/native/definitions.ts +11 -19
  63. package/src/native/index.ts +67 -30
  64. package/src/native/prehide.ts +7 -4
  65. package/src/native/runtime.ts +40 -18
  66. package/src/native/shared/candidate.ts +10 -0
  67. package/src/native/shared/dom.ts +11 -10
  68. package/src/native/shared/modal.ts +157 -0
  69. package/src/native/vertical-bars-web.ts +12 -3
  70. package/src/sheets-of-glass/animations.ts +66 -41
  71. package/src/sheets-of-glass/index.ts +82 -16
  72. package/src/styles/components/ion-tabs.scss +7 -4
  73. package/src/styles/vertical-bars.scss +110 -35
  74. package/src/tab-bar-searchable/index.ts +40 -9
  75. package/src/vertical-bars.ts +1 -2
@@ -22,6 +22,18 @@ final class ShellSnapshotTests: XCTestCase {
22
22
  try JSValueDecoder().decode(ShellSnapshot.self, from: ["revision": 1, "viewportWidth": width, "controls": controls])
23
23
  }
24
24
 
25
+ func testModalRailFrameIsOptionalAndRequiresPositiveBounds() throws {
26
+ var payload: JSObject = ["revision": 1, "viewportWidth": 466.0, "controls": [control()]]
27
+ let page = try JSValueDecoder().decode(ShellSnapshot.self, from: payload)
28
+ XCTAssertNil(page.verticalBarFrame)
29
+ for height in [339.0, 0.0, -1.0] {
30
+ payload["verticalBarFrame"] = ["x": 0.0, "y": 339.0, "width": 466.0, "height": height]
31
+ let modal = try JSValueDecoder().decode(ShellSnapshot.self, from: payload)
32
+ XCTAssertEqual(modal.isValid, height > 0)
33
+ XCTAssertEqual(modal.verticalBarFrame?.y, 339)
34
+ }
35
+ }
36
+
25
37
  @MainActor func testTabTypographyPreservesCSSWeightsAndSize() throws {
26
38
  let weights: [UIFont.Weight] = [.ultraLight, .thin, .light, .regular, .medium, .semibold, .bold, .heavy, .black]
27
39
  let tab = UITabBarItem()
@@ -37,6 +49,24 @@ final class ShellSnapshotTests: XCTestCase {
37
49
  }
38
50
  }
39
51
 
52
+ @MainActor func testVerticalBarsPreserveButtonAppearance() throws {
53
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive controls") }
54
+ let controls = try decode([control(["kind": "ion-buttons", "items": [
55
+ item(["backgroundColor": "rgb(0, 122, 255)"]),
56
+ item(["id": "outline", "borderColor": "rgb(255, 0, 0)", "borderWidth": 2.0])
57
+ ]])]).controls
58
+ XCTAssertEqual(controls.first?.items.first?.content.backgroundColor, "rgb(0, 122, 255)")
59
+ let model = ShellVerticalBarsModel()
60
+ model.apply(controls, rendering: ShellRendering())
61
+ XCTAssertNotNil(model.groups.first?.items.first?.background)
62
+ XCTAssertNotNil(model.groups.first?.items.last?.borderColor)
63
+ XCTAssertEqual(model.groups.first?.items.last?.borderWidth, 2)
64
+ model.apply(try decode([control(["kind": "ion-button"])]).controls, rendering: ShellRendering())
65
+ XCTAssertNil(model.groups.first?.items.first?.background)
66
+ XCTAssertNil(model.groups.first?.items.first?.borderColor)
67
+ XCTAssertEqual(model.groups.first?.items.first?.borderWidth, 0)
68
+ }
69
+
40
70
  @MainActor func testVerticalBarsTabOptimismWaitsForWebAndRollsBackWhenStale() throws {
41
71
  guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive tabs") }
42
72
  func tabs(_ selected: String, includeRight: Bool = true) throws -> [ShellControl] {
@@ -577,4 +607,41 @@ final class ShellSnapshotTests: XCTestCase {
577
607
  XCTAssertEqual(phases, ["input"])
578
608
  }
579
609
 
610
+ func testVerticalSearchBridgesInputFocusAndDismissalWithoutStaleWebEchoes() throws {
611
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native glass search") }
612
+ func node(active: Bool, value: String = "", version: Int = 0) throws -> ShellControl {
613
+ let search: JSObject = ["id": "search", "field": item(),
614
+ "trigger": item(["id": "trigger"]), "closeId": "close", "active": active,
615
+ "available": true, "focused": false, "value": value, "placeholder": "Search",
616
+ "disabled": false, "editSequence": 0, "valueVersion": version]
617
+ return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "placement": "vertical-bars",
618
+ "search": search, "items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
619
+ }
620
+ var actions: [String] = []
621
+ var values: [String] = []
622
+ let rail = UIView()
623
+ let model = ShellVerticalSearchModel(try XCTUnwrap(node(active: false).search), in: rail,
624
+ activate: { actions.append($0) },
625
+ changed: { _, _, value, _, _ in values.append(value); return values.count })
626
+ model.present(true)
627
+ XCTAssertEqual(actions, ["trigger"])
628
+ model.apply(try XCTUnwrap(node(active: true).search))
629
+ XCTAssertTrue(model.presented)
630
+ model.input("native query")
631
+ XCTAssertEqual(values, ["native query"])
632
+ model.apply(try XCTUnwrap(node(active: true).search))
633
+ XCTAssertEqual(model.text, "native query")
634
+ // The first presentation acknowledgement must not undo native focus.
635
+ model.focus(true)
636
+ model.apply(try XCTUnwrap(node(active: true).search))
637
+ XCTAssertTrue(model.focused)
638
+ model.apply(try XCTUnwrap(node(active: true, value: "application", version: 1).search))
639
+ XCTAssertEqual(model.text, "application")
640
+ model.present(false)
641
+ XCTAssertEqual(actions, ["trigger", "close"])
642
+ model.apply(try XCTUnwrap(node(active: false).search))
643
+ XCTAssertFalse(model.presented)
644
+ XCTAssertFalse(model.focused)
645
+ }
646
+
580
647
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rdlabo/ionic-theme-ios27",
3
3
  "private": false,
4
- "version": "1.2.0-1",
4
+ "version": "1.2.0-3",
5
5
  "description": "iOS27 Theme for Ionic Framework",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
package/src/index.ts CHANGED
@@ -17,24 +17,55 @@ export const registerTabBarEffect = (targetElement: HTMLElement): registeredEffe
17
17
  const reducedMotion = win.matchMedia('(prefers-reduced-motion: reduce)');
18
18
  const verticalBarsRoot = targetElement.closest('ion-app');
19
19
  let effect: registeredEffect | undefined;
20
+ let placement: 'disabled' | 'horizontal' | 'left' | 'right' | undefined;
20
21
  const update = () => {
21
- effect?.destroy();
22
22
  const isVertical =
23
- !!verticalBarsRoot?.classList.contains('ios-theme-vertical-bars') && !targetElement.closest('ion-menu, ion-modal, ion-popover');
24
- effect =
25
- reducedMotion.matches || isVertical
26
- ? undefined
27
- : registerEffect(targetElement, 'ion-tab-button', 'tab-selected', {
23
+ !!verticalBarsRoot?.classList.contains('ios-theme-vertical-bars') &&
24
+ targetElement.matches('ion-tabs > ion-tab-bar:not(.ios-theme-disabled, .ios26-disabled)') &&
25
+ !targetElement.closest('ion-menu, ion-modal, ion-popover');
26
+ const disabled = reducedMotion.matches || !targetElement.matches('.ios:not(.ios-theme-disabled, .ios26-disabled)');
27
+ const nextPlacement = disabled
28
+ ? 'disabled'
29
+ : isVertical
30
+ ? verticalBarsRoot?.classList.contains('ios-theme-vertical-bars-left')
31
+ ? 'left'
32
+ : 'right'
33
+ : 'horizontal';
34
+ // Gesture/activation classes also change during interaction. Rebuild only when
35
+ // eligibility or placement changes, retaining cancellation when the rail changes sides.
36
+ if (placement === nextPlacement) return;
37
+ placement = nextPlacement;
38
+ effect?.destroy();
39
+ effect = disabled
40
+ ? undefined
41
+ : registerEffect(
42
+ targetElement,
43
+ 'ion-tab-button',
44
+ 'tab-selected',
45
+ {
28
46
  small: 'scale(1.1, 1)',
29
47
  medium: 'scale(1.2)',
30
48
  large: 'scale(1.15, 1.4)',
31
49
  xlarge: 'scale(1.3)',
32
- });
50
+ },
51
+ isVertical,
52
+ );
33
53
  };
34
54
  update();
35
55
  reducedMotion.addEventListener('change', update);
36
- const placementObserver = new win.MutationObserver(update);
37
- if (verticalBarsRoot) placementObserver.observe(verticalBarsRoot, { attributes: true, attributeFilter: ['class'] });
56
+ const placementObserver = new win.MutationObserver((records) => {
57
+ if (
58
+ records.some((record) =>
59
+ record.type === 'attributes'
60
+ ? record.target === targetElement || record.target === verticalBarsRoot
61
+ : [...Array.from(record.addedNodes), ...Array.from(record.removedNodes)].some((node) => node.contains(targetElement)),
62
+ )
63
+ )
64
+ update();
65
+ });
66
+ if (verticalBarsRoot)
67
+ placementObserver.observe(verticalBarsRoot, { attributes: true, attributeFilter: ['class'], childList: true, subtree: true });
68
+ placementObserver.observe(targetElement, { attributes: true, attributeFilter: ['class'] });
38
69
  return {
39
70
  destroy: () => {
40
71
  reducedMotion.removeEventListener('change', update);
@@ -31,7 +31,12 @@ export const isVerticalBarsCandidate = isVerticalBarsSource;
31
31
 
32
32
  export const readCandidate = (element: HTMLElement, id: Identify): Candidate | undefined => {
33
33
  const verticalBars = isVerticalBarsCandidate(element);
34
- if ((!element.classList.contains('ios') && !verticalBars) || !visible(element, verticalBars) || element.closest('ion-modal, ion-popover'))
34
+ if (
35
+ (!element.classList.contains('ios') && !verticalBars) ||
36
+ !visible(element, verticalBars) ||
37
+ element.closest('ion-popover') ||
38
+ (element.closest('ion-modal') && !verticalBars)
39
+ )
35
40
  return;
36
41
  const style = getComputedStyle(element);
37
42
  if (
@@ -1,3 +1,4 @@
1
+ import { modalUsesVerticalBars } from '../shared/modal';
1
2
  import { createCandidate, appendItem } from '../shared/candidate';
2
3
  import type { Candidate, Identify } from '../shared/candidate';
3
4
  import { inFixedToolbar, isVerticalBarsToolbarAction, isVerticalBarsToolbarGroup } from '../shared/dom';
@@ -6,15 +7,15 @@ export const tag = 'ion-button';
6
7
 
7
8
  export const read = (element: HTMLElement, id: Identify): Candidate | undefined => {
8
9
  const button = element as HTMLIonButtonElement;
9
- const verticalBars = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest('ion-app.ios-theme-vertical-bars');
10
+ const verticalBars =
11
+ !element.closest('ion-menu, ion-popover') && modalUsesVerticalBars(element) && !!element.closest('ion-app.ios-theme-vertical-bars');
10
12
  if (verticalBars && element.parentElement && isVerticalBarsToolbarGroup(element.parentElement)) return;
11
13
  const fill = button.fill ?? 'default';
12
14
  if (
13
15
  !inFixedToolbar(element) ||
14
16
  (verticalBars && !isVerticalBarsToolbarAction(element)) ||
15
17
  (!verticalBars && fill !== 'default') ||
16
- (verticalBars && !['default', 'clear'].includes(fill)) ||
17
- button.classList.contains('ion-color')
18
+ (!verticalBars && button.classList.contains('ion-color'))
18
19
  )
19
20
  return;
20
21
  const candidate = createCandidate(element, tag, id);
@@ -1,3 +1,4 @@
1
+ import { modalUsesVerticalBars } from '../shared/modal';
1
2
  import { createCandidate, appendItem } from '../shared/candidate';
2
3
  import type { Candidate, Identify } from '../shared/candidate';
3
4
  import { childElements, verticalBarsToolbarActions, inFixedToolbar, isVerticalBarsToolbarGroup } from '../shared/dom';
@@ -10,7 +11,8 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined
10
11
  if (!inFixedToolbar(element)) return;
11
12
  let children = childElements(element);
12
13
  if (children.length === 1) return menuButton.read(element, id);
13
- const verticalBars = !element.closest('ion-menu, ion-modal, ion-popover') && !!element.closest('ion-app.ios-theme-vertical-bars');
14
+ const verticalBars =
15
+ !element.closest('ion-menu, ion-popover') && modalUsesVerticalBars(element) && !!element.closest('ion-app.ios-theme-vertical-bars');
14
16
  if (verticalBars && !isVerticalBarsToolbarGroup(element)) return;
15
17
  if (verticalBars) children = verticalBarsToolbarActions(element);
16
18
  if (
@@ -19,7 +21,7 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined
19
21
  (child) =>
20
22
  !child.matches(`${menuButton.tag}${verticalBars ? '' : '.ios'}`) &&
21
23
  (!child.matches(`ion-button${verticalBars ? '' : '.ios.button-clear'}`) ||
22
- !(verticalBars ? ['default', 'clear'] : ['clear']).includes((child as HTMLIonButtonElement).fill ?? 'default')),
24
+ (!verticalBars && (child as HTMLIonButtonElement).fill !== 'clear')),
23
25
  )
24
26
  )
25
27
  return;
@@ -167,7 +167,12 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
167
167
  keyboard(visible: boolean) {
168
168
  keyboardVisible = visible;
169
169
  if (!visible) {
170
- states.forEach((state) => (state.keyboardHidden = false));
170
+ for (const [binding, state] of states) {
171
+ // Ionic can wait for the pre-keyboard height indefinitely after rotation.
172
+ // Release the visibility override we own when native search's keyboard closes.
173
+ if (state.keyboardHidden) binding.tabBar.classList.remove('tab-bar-hidden');
174
+ state.keyboardHidden = false;
175
+ }
171
176
  releaseKeyboardResize();
172
177
  }
173
178
  schedule();
@@ -202,15 +207,7 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
202
207
  };
203
208
  for (const binding of bindings) {
204
209
  const candidate = candidates.find((c) => c.element === binding.tabBar);
205
- // The vertical rail has no search surface; hiding the FAB/footer there
206
- // would leave searchable tabs without any search control.
207
- if (
208
- !candidate ||
209
- candidate.control.search ||
210
- candidate.control.placement === 'vertical-bars' ||
211
- binding.tabBar.getAttribute('slot') !== 'bottom'
212
- )
213
- continue;
210
+ if (!candidate || candidate.control.search || binding.tabBar.getAttribute('slot') !== 'bottom') continue;
214
211
  const fab = binding.trigger.parentElement;
215
212
  // Search also supports the existing page-level fixed FAB, outside scrolling content.
216
213
  if (
@@ -441,8 +438,8 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str
441
438
  },
442
439
  retire(binding: NativeSearchBinding) {
443
440
  const state = states.get(binding);
441
+ // Keep ownership until keyboardWillHide can release this class.
444
442
  if (state?.keyboardHidden && keyboardVisible) binding.tabBar.classList.add('tab-bar-hidden');
445
- if (state) state.keyboardHidden = false;
446
443
  if (state?.focused) {
447
444
  state.focused = false;
448
445
  state.input.dispatchEvent(new FocusEvent('blur'));
@@ -44,14 +44,10 @@ export type VerticalBarEdge = 'leading' | 'trailing' | null;
44
44
 
45
45
  export interface VerticalBarPlacement {
46
46
  edge: VerticalBarEdge;
47
- /** UIKit safe-area inset on the vertical-bar edge, in points. */
48
- inset: number;
49
- }
50
-
51
- export enum HingeStatus {
52
- Closed = 'closed',
53
- PartiallyOpen = 'partiallyOpen',
54
- FullyOpen = 'fullyOpen',
47
+ /** Explicit rail width in CSS pixels; omitted to use the stylesheet's safe-area rules. */
48
+ inset?: number;
49
+ /** Native logical edge reported by the application's device plugin. Null or an unregistered edge uses a Web rail in verticalBarsOnly mode, or the ordinary Native UI Shell layout otherwise. Omission keeps the last supplied value. */
50
+ nativeEdge?: VerticalBarEdge;
55
51
  }
56
52
 
57
53
  export interface VerticalControlAreaHandle extends NativeUIShellHandle {
@@ -86,6 +82,9 @@ export interface ShellItem extends Frame {
86
82
  fontSize: number;
87
83
  fontWeight: number;
88
84
  color: string;
85
+ backgroundColor?: string;
86
+ borderColor?: string;
87
+ borderWidth?: number;
89
88
  badge?: ShellBadge;
90
89
  icon?: string;
91
90
  iconWidth?: number;
@@ -144,6 +143,8 @@ export interface ShellSnapshot {
144
143
  viewportWidth: number;
145
144
  /** Physical side the native rail is drawn on; the runtime resolves the logical edge through the document direction. */
146
145
  verticalBarEdge?: 'left' | 'right';
146
+ /** Visible foreground modal bounds; omitted for the full page. */
147
+ verticalBarFrame?: Frame;
147
148
  controls: ShellControl[];
148
149
  }
149
150
 
@@ -158,21 +159,12 @@ export interface WebViewMetrics {
158
159
  radius: number;
159
160
  }
160
161
 
161
- export interface DeviceLayout {
162
- placement: VerticalBarPlacement;
163
- /** Fold hinge posture, or `null` when the device reports no hinge. */
164
- hingeStatus: HingeStatus | null;
165
- webViewMetrics: WebViewMetrics;
166
- }
167
-
168
162
  export interface NativeUIShellPlugin {
169
163
  configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>;
170
- getDeviceLayout(): Promise<DeviceLayout>;
171
- startDeviceLayoutMonitoring(): Promise<void>;
172
- stopDeviceLayoutMonitoring(): Promise<void>;
164
+ getWebViewMetrics(): Promise<WebViewMetrics>;
173
165
  update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>;
174
166
  clear(options: { revision: number }): Promise<void>;
175
167
  addListener(name: 'activate', listener: (event: ShellActivation) => void): Promise<PluginListenerHandle>;
176
168
  addListener(name: 'search', listener: (event: ShellSearchEvent) => void): Promise<PluginListenerHandle>;
177
- addListener(name: 'deviceLayoutChange', listener: (event: DeviceLayout) => void): Promise<PluginListenerHandle>;
169
+ addListener(name: 'webViewMetricsChange', listener: (event: WebViewMetrics) => void): Promise<PluginListenerHandle>;
178
170
  }
@@ -12,6 +12,7 @@ import type {
12
12
  import { createRuntime } from './runtime';
13
13
  import { createVerticalBarsWebProjection } from './vertical-bars-web';
14
14
  import { prehideVerticalBarsToolbarSources } from './prehide';
15
+ import { observeVerticalBarsModals } from './shared/modal';
15
16
  export type {
16
17
  NativeUIShellComponent,
17
18
  NativeUIShellControls,
@@ -19,13 +20,11 @@ export type {
19
20
  NativeUIShellOptions,
20
21
  NativeUIShellStatus,
21
22
  NativeUIShellSuspension,
22
- DeviceLayout,
23
23
  VerticalBarEdge,
24
24
  VerticalBarPlacement,
25
25
  VerticalControlAreaHandle,
26
26
  WebViewMetrics,
27
27
  } from './definitions';
28
- export { HingeStatus } from './definitions';
29
28
 
30
29
  const plugin = registerPlugin<NativeUIShellPlugin>('IonicNativeUIShell');
31
30
  export const IonicNativeUIShell = plugin;
@@ -97,8 +96,7 @@ const manage = (
97
96
 
98
97
  /** Reads the current native WebView geometry and applies it to page transitions. */
99
98
  export const configureNativeTransition = async (): Promise<WebViewMetrics> => {
100
- const metrics =
101
- typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? (await plugin.getDeviceLayout()).webViewMetrics : { radius: 0 };
99
+ const metrics = typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? await plugin.getWebViewMetrics() : { radius: 0 };
102
100
  setConfig({ radius: metrics.radius });
103
101
  return metrics;
104
102
  };
@@ -109,6 +107,39 @@ const physicalVerticalBarEdge = (edge: Exclude<VerticalBarEdge, null>, rtl: bool
109
107
 
110
108
  const elementRtl = (element: Element): boolean => element.closest('[dir]')?.getAttribute('dir') === 'rtl';
111
109
 
110
+ // Device facts are supplied by the application; the theme only compares placement.
111
+ const nativePlacements = new WeakMap<HTMLElement, { edge: VerticalBarEdge; rtl?: boolean }>();
112
+ const horizontalFallbackAttribute = 'data-native-ui-shell-vertical-bars-suspended';
113
+
114
+ // Keep the requested rail while ordinary Native UI Shell temporarily uses its
115
+ // horizontal layout. Removing the effective class restores normal measurements,
116
+ // toolbar ownership and safe areas throughout the existing rendering pipeline.
117
+ const observeNativeVerticalBarsLayout = (doc: Document): (() => void) => {
118
+ const reconcile = () => {
119
+ const app = doc.querySelector<HTMLElement>('ion-app');
120
+ if (!app) return;
121
+ const requested = app.classList.contains('ios-theme-vertical-bars') || app.hasAttribute(horizontalFallbackAttribute);
122
+ const suspended = requested && nativePlacements.get(app)?.edge == null;
123
+ if (app.hasAttribute(horizontalFallbackAttribute) !== suspended) app.toggleAttribute(horizontalFallbackAttribute, suspended);
124
+ if (app.classList.contains('ios-theme-vertical-bars') !== (requested && !suspended)) {
125
+ app.classList.toggle('ios-theme-vertical-bars', requested && !suspended);
126
+ }
127
+ };
128
+ const observer = new MutationObserver(reconcile);
129
+ observer.observe(doc.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
130
+ doc.defaultView?.addEventListener('nativeUIShellRefresh', reconcile);
131
+ reconcile();
132
+ return () => {
133
+ observer.disconnect();
134
+ doc.defaultView?.removeEventListener('nativeUIShellRefresh', reconcile);
135
+ const app = doc.querySelector<HTMLElement>(`ion-app[${horizontalFallbackAttribute}]`);
136
+ if (app) {
137
+ app.removeAttribute(horizontalFallbackAttribute);
138
+ app.classList.add('ios-theme-vertical-bars');
139
+ }
140
+ };
141
+ };
142
+
112
143
  /**
113
144
  * Applies one placement to the CSS layout and both Web/native projections.
114
145
  * Pass `rtl` when the document direction is known; otherwise the nearest `dir` attribute is used.
@@ -117,11 +148,16 @@ export const setVerticalControlAreaPlacement = (placement: VerticalBarEdge | Ver
117
148
  if (typeof document === 'undefined') return;
118
149
  const app = document.querySelector<HTMLElement>('ion-app');
119
150
  if (!app) throw new Error('Vertical Control Area requires ion-app');
120
- const { edge, inset } = placement && typeof placement === 'object' ? placement : { edge: placement, inset: 0 };
151
+ const { edge, inset = 0 } = placement && typeof placement === 'object' ? placement : { edge: placement, inset: 0 };
152
+ if (placement && typeof placement === 'object' && placement.nativeEdge !== undefined) {
153
+ nativePlacements.set(app, { edge: placement.nativeEdge, rtl });
154
+ }
155
+ app.removeAttribute(horizontalFallbackAttribute);
121
156
  app.classList.toggle('ios-theme-vertical-bars', edge !== null);
122
157
  app.classList.toggle('ios-theme-vertical-bars-left', edge !== null && physicalVerticalBarEdge(edge, rtl ?? elementRtl(app)) === 'left');
123
158
  if (edge && Number.isFinite(inset) && inset > 0) app.style.setProperty('--ios-theme-vertical-bars-native-inset', `${inset}px`);
124
159
  else app.style.removeProperty('--ios-theme-vertical-bars-native-inset');
160
+ document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh'));
125
161
  };
126
162
 
127
163
  /** Call once at application startup. Ionic markup remains the source of truth. */
@@ -160,6 +196,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
160
196
  );
161
197
  if (active) return active;
162
198
  activeConfiguration = configuration;
199
+ const stopModals = observeVerticalBarsModals(document);
163
200
  const prehide =
164
201
  options.controls === undefined || options.controls.toolbar === true ? prehideVerticalBarsToolbarSources(document) : undefined;
165
202
  // A slow destroy of a superseded activation must not release a newer one.
@@ -174,38 +211,37 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
174
211
  manage(createVerticalBarsWebProjection(document, options), {
175
212
  reason,
176
213
  suspend: () => prehide?.suspend(),
177
- destroy: () => prehide?.stop(),
214
+ destroy: () => {
215
+ prehide?.stop();
216
+ stopModals();
217
+ },
178
218
  release,
179
219
  });
180
220
  return (start = active =
181
221
  (async () => {
182
222
  if (Capacitor.getPlatform() !== 'ios') return fallback('Requires Capacitor iOS');
183
223
  let runtime: NativeUIShellHandle | undefined;
184
- let placementListener: Awaited<ReturnType<typeof plugin.addListener>> | undefined;
185
- let monitoring = false;
224
+ let metricsListener: Awaited<ReturnType<typeof plugin.addListener>> | undefined;
225
+ let stopVerticalBarsLayout: (() => void) | undefined;
186
226
  try {
187
- if (!options.verticalBarsOnly) await configureNativeTransition().catch(() => undefined);
188
227
  const capabilities = await plugin.configure({ verticalBarsOnly: options.verticalBarsOnly === true });
189
228
  if (!capabilities.supported) return fallback('Requires iOS 26 or later');
190
- let nativeEdge: VerticalBarEdge = null;
229
+ if (!options.verticalBarsOnly) stopVerticalBarsLayout = observeNativeVerticalBarsLayout(document);
230
+ // The application owns device state and selects the rail through placement classes.
191
231
  const nativeVerticalBars = () => {
192
- const root = document.querySelector('ion-app.ios-theme-vertical-bars');
193
- if (!root) return false;
194
- // The trait stays unspecified when the OS cannot report a rail — for
195
- // example an app linked against an SDK older than 27.1 — so the DOM
196
- // class is trusted there. When the OS does report an edge, the native
197
- // rail only takes over once the app has applied the matching class.
198
- const domEdge = root.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right';
199
- return nativeEdge === null || physicalVerticalBarEdge(nativeEdge, elementRtl(root)) === domEdge;
232
+ const app = document.querySelector<HTMLElement>('ion-app.ios-theme-vertical-bars');
233
+ if (!app) return false;
234
+ const reported = nativePlacements.get(app);
235
+ if (!reported?.edge) return false;
236
+ const physicalEdge = app.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right';
237
+ return physicalEdge === physicalVerticalBarEdge(reported.edge, reported.rtl ?? elementRtl(app));
200
238
  };
201
- await plugin.startDeviceLayoutMonitoring();
202
- monitoring = true;
203
- placementListener = await plugin.addListener('deviceLayoutChange', ({ placement, webViewMetrics }) => {
204
- nativeEdge = placement.edge;
205
- if (!options.verticalBarsOnly) setConfig({ radius: webViewMetrics.radius });
206
- document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh'));
207
- });
208
- nativeEdge = (await plugin.getDeviceLayout()).placement.edge;
239
+ if (!options.verticalBarsOnly) {
240
+ metricsListener = await plugin.addListener('webViewMetricsChange', (metrics) => {
241
+ setConfig({ radius: metrics.radius });
242
+ });
243
+ await configureNativeTransition().catch(() => undefined);
244
+ }
209
245
  const native = await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true);
210
246
  runtime = combine(
211
247
  native,
@@ -215,15 +251,16 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
215
251
  suspend: () => prehide?.suspend(),
216
252
  release,
217
253
  destroy: async () => {
218
- await placementListener?.remove().catch(() => {});
219
- if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {});
254
+ await metricsListener?.remove().catch(() => {});
255
+ stopVerticalBarsLayout?.();
220
256
  prehide?.stop();
257
+ stopModals();
221
258
  },
222
259
  });
223
260
  } catch (error) {
224
261
  await runtime?.destroy();
225
- await placementListener?.remove().catch(() => {});
226
- if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {});
262
+ await metricsListener?.remove().catch(() => {});
263
+ stopVerticalBarsLayout?.();
227
264
  return fallback(error instanceof Error ? error.message : String(error));
228
265
  }
229
266
  })());
@@ -1,3 +1,4 @@
1
+ import { modalUsesVerticalBars } from './shared/modal';
1
2
  import { LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE, LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE } from '@ionic/core';
2
3
  import { VERTICAL_BARS_TRANSITION_CANCELED } from '../native-integration';
3
4
  import {
@@ -17,7 +18,7 @@ import {
17
18
  setVerticalBarsPlacement,
18
19
  } from './shared/dom';
19
20
 
20
- const overlays = 'ion-menu, ion-modal, ion-popover';
21
+ const overlays = 'ion-menu, ion-popover';
21
22
  const sourceSelector = 'ion-back-button, ion-menu-button, ion-buttons, ion-button';
22
23
  // Realm- and teardown-safe: globals such as Element/HTMLElement may be gone
23
24
  // when a queued mutation microtask or a stale listener still runs.
@@ -60,7 +61,7 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: ()
60
61
  scopes.delete(scope);
61
62
  };
62
63
  const capture = (scope: HTMLElement) => {
63
- if (!root()?.contains(scope) || scope.closest(overlays) || verticalBarsPages.isDeparted(scope)) return;
64
+ if (!root()?.contains(scope) || scope.closest(overlays) || !modalUsesVerticalBars(scope) || verticalBarsPages.isDeparted(scope)) return;
64
65
  const owned = scopes.get(scope) ?? new Set<HTMLElement>();
65
66
  const place = (element: HTMLElement, rail: boolean) => {
66
67
  if (owned.has(element)) return;
@@ -73,7 +74,8 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: ()
73
74
  };
74
75
  const sources = scope.matches('ion-back-button') ? [scope] : Array.from(scope.querySelectorAll<HTMLElement>(sourceSelector));
75
76
  sources.forEach((element) => {
76
- if (element.closest(overlays) || (scope.matches('.ion-page') && routedPage(element) !== scope)) return;
77
+ if (!modalUsesVerticalBars(element) || element.closest(overlays) || (scope.matches('.ion-page') && routedPage(element) !== scope))
78
+ return;
77
79
  if (element.matches('ion-buttons')) {
78
80
  const children = childElements(element);
79
81
  const actions = children.filter((child) => eligible(child) && isVerticalBarsToolbarActionShape(child));
@@ -134,7 +136,7 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: ()
134
136
  Array.from(scopes.keys()).forEach(release);
135
137
  return;
136
138
  }
137
- for (const scope of scopes.keys()) if (!scope.isConnected) release(scope);
139
+ for (const scope of scopes.keys()) if (!scope.isConnected || !modalUsesVerticalBars(scope)) release(scope);
138
140
  for (const [element, pending] of pendingBacks) if (element.shadowRoot || element.classList.contains('hydrated')) capture(pending.scope);
139
141
  // Ionic inserts the destination as invisible before WillEnter. Hide its
140
142
  // sources in that same mutation microtask; capture new DOM identities on active pages too.
@@ -193,6 +195,7 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: ()
193
195
  for (const name of [LIFECYCLE_WILL_LEAVE, LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE])
194
196
  doc.addEventListener(name, onPageLifecycle, { capture: true, signal: listeners.signal });
195
197
  doc.addEventListener(VERTICAL_BARS_TRANSITION_CANCELED, onTransitionCanceled, { capture: true, signal: listeners.signal });
198
+ doc.defaultView?.addEventListener('nativeUIShellRefresh', reconcile, { signal: listeners.signal });
196
199
  reconcile();
197
200
  const mutationRelevant = (record: MutationRecord) => {
198
201
  if (record.type === 'childList')