@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
@@ -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
 
@@ -10,13 +10,12 @@ Adapt your Ionic app to iPhone Duo: place navigation and actions in its vertical
10
10
 
11
11
  Available in `1.2.0-0` as an **experimental** feature alongside [Native UI Shell](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/native-ui-shell). APIs and supported behavior may change. The real system rail and hinge reporting require iOS 27.1 or later and an app built with Xcode 27.1 or newer.
12
12
 
13
- This package provides three independent pieces for that hardware. Each works **without the iOS 27 theme stylesheets** and **without the full Native UI Shell**:
13
+ This package provides two independent pieces for that hardware. Each works **without the iOS 27 theme stylesheets** and **without the full Native UI Shell**:
14
14
 
15
15
  - `dist/css/vertical-bars.css` — opt-in classes that reserve the rail's safe area, plus a registered custom property for a posture-driven split-pane width.
16
16
  - `enableVerticalControlArea()` — moves eligible tabs and toolbar controls into the reserved area. On Capacitor iOS they are rendered by a native SwiftUI `TabView` and toolbar; everywhere else the same controls appear as Web clones.
17
- - Device-layout reporting — the bundled Capacitor plugin reports rail placement, hinge status and the WebView corner radius through `getDeviceLayout()` and the `deviceLayoutChange` event.
18
17
 
19
- The pieces map to distinct responsibilities. The plugin reports **device facts** and never touches the DOM. The **application** decides what each value means for its layout. The **stylesheet and runtime** apply that decision — reserving space, projecting controls and adapting the split pane. Keeping these boundaries separate makes the native values easy to mock in tests and keeps the theme's own responsibility small.
18
+ Device state belongs to [`@erkamyaman/capacitor-foldable`](https://github.com/erkamyaman/capacitor-foldable). The **application** subscribes to its events and chooses its layout. This package's **stylesheet and runtime** apply that decision by reserving space, projecting controls and adapting the split pane. The theme does not monitor hinge state or bar placement.
20
19
 
21
20
  ## Choose what to adopt
22
21
 
@@ -24,10 +23,10 @@ To keep your existing theme and add only the standalone support, follow [iPhone
24
23
 
25
24
  | Goal | Stylesheet | Runtime |
26
25
  | ------------------------------------------------ | ------------------- | ------------------------------------------------------------------ |
27
- | Hinge posture only (layout switches) | none | none — subscribe to the plugin directly |
28
- | Posture-driven split-pane width | `vertical-bars.css` | none — subscribe to the plugin directly |
26
+ | Hinge posture only (layout switches) | none | none — subscribe to `Foldable` directly |
27
+ | Posture-driven split-pane width | `vertical-bars.css` | none — subscribe to `Foldable` directly |
29
28
  | Vertical rail for tabs and toolbar actions | `vertical-bars.css` | `enableVerticalControlArea()` |
30
- | Native shell plus the rail | `vertical-bars.css` | `enableNativeUIShell()` — already includes rail and posture support |
29
+ | Native shell plus the rail | `vertical-bars.css` | `enableNativeUIShell()` — includes rail projection |
31
30
 
32
31
  ```scss
33
32
  @use '@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css';
@@ -39,39 +38,49 @@ The `/vertical-bars` entry point imports `@capacitor/core` at module load, so in
39
38
 
40
39
  ## Read the device layout
41
40
 
42
- `npx cap sync ios` registers the plugin automatically; no `configure` call is needed for device layout. An app that only wants the hinge posture — for example to drive a split pane — uses this API alone, with no projection runtime:
41
+ Install the device-state plugin in the application, then sync the native project:
42
+
43
+ ```bash
44
+ npm install @erkamyaman/capacitor-foldable
45
+ npx cap sync
46
+ ```
47
+
48
+ Use Capacitor 8.5 or later and build with Xcode 27.1 or newer for iPhone Duo's iOS 27.1 APIs. The dependency is needed for device-driven layout, not for the theme's CSS, browser simulation, or native control projection alone. Do not import the plugin's `ionic-tabs.css` alongside this package's rail projection; both would reposition the same tabs.
49
+
50
+ For a posture-driven split pane, subscribe directly without starting a projection runtime:
43
51
 
44
52
  ```ts
45
- import { Capacitor } from '@capacitor/core';
46
- import { HingeStatus, IonicNativeUIShell } from '@rdlabo/ionic-theme-ios27/vertical-bars';
53
+ import { Foldable, type FoldState } from '@erkamyaman/capacitor-foldable';
47
54
 
48
- // The plugin has no Web implementation; guard the subscription.
49
- if (Capacitor.getPlatform() === 'ios') {
50
- await IonicNativeUIShell.startDeviceLayoutMonitoring();
51
- const listener = await IonicNativeUIShell.addListener('deviceLayoutChange', ({ hingeStatus }) => {
52
- // apply the posture
53
- });
54
- const { hingeStatus } = await IonicNativeUIShell.getDeviceLayout(); // initial value
55
-
56
- // When the consumer goes away:
57
- // await listener.remove();
58
- // await IonicNativeUIShell.stopDeviceLayoutMonitoring();
59
- }
55
+ const applyFold = (fold: FoldState) => {
56
+ const pane = document.querySelector('ion-split-pane');
57
+ pane?.classList.toggle('ios-theme-split-pane-half-open', fold.state === 'half-opened');
58
+ const expanded = fold.state === 'half-opened' || (fold.state === 'flat' && !!fold.hingeBounds);
59
+ pane?.setAttribute('when', expanded ? '(min-width: 900px)' : '(min-width: 992px)');
60
+ };
61
+ let receivedEvent = false;
62
+ let disposed = false;
63
+ const listener = await Foldable.addListener('foldStateChange', (fold) => {
64
+ receivedEvent = true;
65
+ if (!disposed) applyFold(fold);
66
+ });
67
+ const initialFold = await Foldable.getFoldState();
68
+ if (!disposed && !receivedEvent) applyFold(initialFold);
69
+
70
+ // When the consumer goes away:
71
+ // disposed = true;
72
+ // await listener.remove();
60
73
  ```
61
74
 
62
- `DeviceLayout` carries:
75
+ `getFoldState()` and `foldStateChange` report `state` (`'flat'`, `'half-opened'`, or `'closed'`), `posture`, and optional hinge geometry. Without fold information, the plugin returns a flat state without `hingeBounds`; restore the ordinary split-pane breakpoint in that case. The Web implementation also returns a flat state. A half-opened state uses the 900px breakpoint even without hinge geometry; a flat state with hinge geometry also uses 900px. A closed state restores the ordinary 992px breakpoint. Events received during initialization take precedence over the initial read.
63
76
 
64
- | Field | Meaning |
65
- | ----------------------- | ---------------------------------------------------------------------------------------------------------- |
66
- | `placement` | `{ edge: 'leading' \| 'trailing' \| null, inset }` — the rail's logical edge in the reading direction and its UIKit safe-area inset in points; `edge` is `null` on devices without a rail |
67
- | `hingeStatus` | `HingeStatus.Closed`, `PartiallyOpen`, or `FullyOpen`; `null` when the device reports no hinge |
68
- | `webViewMetrics.radius` | the WebView's effective top-left corner radius in points |
77
+ `getBarPlacement()` and `barPlacementChange` report `{ verticalBarEdge: 'leading' | 'trailing' | null, inset: number }`. The edge is **logical**: leading is the physical left in LTR and the physical right in RTL. Pass `{ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset }` to `setPlacement()`. No start/stop monitoring calls are needed; remove each listener when its owner is disposed.
69
78
 
70
- `edge` is a **logical** direction: `'leading'` is where a reader starts a line — the physical left in LTR and the physical right in RTL. This is the same vocabulary as UIKit's `verticalBarEdge` trait and `@erkamyaman/capacitor-foldable`'s `getBarPlacement()`, so values from that plugin can be applied as-is without conversion.
79
+ The theme never reads UIKit bar-placement traits. The application supplies `nativeEdge` on the initial read and each event, even when choosing a fixed `edge`. Omit `nativeEdge` to keep the last supplied value; pass `null` when the plugin reports no edge. An explicit `nativeEdge: null` or an initial unregistered edge prevents native vertical projection: `enableVerticalControlArea()` keeps the requested Web rail, while the full Native UI Shell temporarily restores its ordinary horizontal layout. The requested rail is retained so a later reported edge can restore vertical layout. Native vertical projection starts only after a matching non-null edge is supplied. Omitting `nativeEdge` after supplying it preserves that value, including `null`. Passing `{ edge: null, nativeEdge }` updates the reported edge while keeping the rail disabled.
71
80
 
72
- Monitoring is reference-counted: each consumer pairs `startDeviceLayoutMonitoring()` with `stopDeviceLayoutMonitoring()`, and events stop when the last consumer releases it. `getDeviceLayout()` also works without monitoring for a one-shot read. While `enableVerticalControlArea()` or `enableNativeUIShell()` has native projection active it already holds a monitoring reference, so those users only add a listener and read the initial value — no extra start/stop pair.
81
+ Foldable reports the measured width reserved by the native bar as `inset` and notifies changes through `barPlacementChange`. Pass this value to `setPlacement()` when following the reported edge, rather than assuming a fixed width. When no bar is reported, `inset` is `0`; the theme clears the explicit width and uses its CSS safe-area rules if the application still requests a Web rail. Applications choosing a different edge can supply their own width or use the CSS fallback. WebView corner radius remains a rendering concern: `configureNativeTransition()` uses the shell's `getWebViewMetrics()` API, independently of `Foldable`.
73
82
 
74
- **Build requirement:** iOS only enables the vertical bar for apps linked against the iOS 27.1 SDK or later — build with Xcode 27.1 or newer. Apps built with an older SDK run in backward-compatibility mode on iPhone Duo: the system reserves no rail, `placement.edge` stays `null`, `inset` stays `0`, and `hingeStatus` stays `null`. Everything else still works in that state — the opt-in classes reserve the DOM strip and the rail follows whatever placement the application applies — so the compat build remains usable and testable; only the real system rail, its measured inset and hinge posture require the newer toolchain.
83
+ **Migration:** the theme's former `DeviceLayout`, `HingeStatus`, `getDeviceLayout()`, `deviceLayoutChange`, and start/stop device-layout monitoring APIs have been removed. Replace device subscriptions with the `Foldable` APIs above; use `getWebViewMetrics()` for one-shot radius reads. Foldable can infer Duo bar placement from safe-area insets when the app is built without the iOS 27.1 SDK. Hinge data still requires the newer SDK. Apps can also request a fixed rail placement independently of the reported edge.
75
84
 
76
85
  ## Reserve the vertical rail
77
86
 
@@ -81,13 +90,13 @@ Add `.ios-theme-vertical-bars` to `ion-app` to reserve the rail region on the ph
81
90
  <ion-app class="ios-theme-vertical-bars">...</ion-app>
82
91
  ```
83
92
 
84
- The classes are physical — `-left` always means the physical left edge — because CSS and the native renderer work in physical coordinates. `setPlacement` (below) is the usual way to apply them: it accepts the logical `placement.edge` reported by the plugin and resolves it through the document's direction, so an RTL app does not need its own conversion.
93
+ The classes are physical — `-left` always means the physical left edge — because CSS and the native renderer work in physical coordinates. `setPlacement` (below) is the usual way to apply them: it accepts the logical `verticalBarEdge` reported by `Foldable` and resolves it through the document's direction, so an RTL app does not need its own conversion.
85
94
 
86
- For Chrome development, no native plugin is needed — the class alone reserves `80px` to simulate iPhone Duo. When `setPlacement` receives a native placement, the measured UIKit inset replaces the simulated width, even when that inset is less than `80px`. Override `--ios-theme-vertical-bars-safe-area-left` or `--ios-theme-vertical-bars-safe-area-right` when simulating a different layout.
95
+ For Chrome development, no native plugin is needed — the class alone reserves `80px` to simulate iPhone Duo. When `setPlacement` receives an explicit `{ edge, inset }`, that inset replaces the fallback width, even when it is less than `80px`. Override `--ios-theme-vertical-bars-safe-area-left` or `--ios-theme-vertical-bars-safe-area-right` when simulating a different layout.
87
96
 
88
97
  This keeps routers and component backgrounds full-viewport. `ion-content` moves its scroll foreground, `ion-toolbar` moves its container foreground, and `ion-fab` adjusts only when placed beside the system UI. The corresponding Ionic safe-area variable is reset inside those foreground components so descendants do not add the inset again.
89
98
 
90
- `ion-menu`, `ion-modal`, and `ion-popover` are handled as separate surfaces: their internal foreground components do not receive the main-page conversion and retain Ionic's standard safe-area handling. A menu presented beside the system UI keeps Ionic's full-viewport animation host and offsets only its visible container by the corresponding inset; a menu from the opposite side is unchanged. Left and right remain physical coordinates in RTL, while Ionic's `side="start"` and `side="end"` values remain logical.
99
+ `ion-modal` applies the same foreground correction when its visible dialog spans the viewport width. With the Vertical Control Area runtime enabled, the topmost full-width modal also projects eligible toolbar buttons into its own rail; centered dialogs keep their toolbar buttons and receive no page-rail inset. This includes full-width sheet modals: their rail follows the visible sheet bounds as the breakpoint changes. Eligibility follows the visible dialog width, not the hinge posture or modal type. `ion-menu` and `ion-popover` are handled as separate surfaces: their internal foreground components do not receive the main-page conversion and retain Ionic's standard safe-area handling. A menu presented beside the system UI keeps Ionic's full-viewport animation host and offsets only its visible container by the corresponding inset; a menu from the opposite side is unchanged. Left and right remain physical coordinates in RTL, while Ionic's `side="start"` and `side="end"` values remain logical.
91
100
 
92
101
  The mode is component-mode independent: an app can keep Ionic `mode: 'md'` on iOS and still enable Vertical Bars. No component needs `mode="ios"`.
93
102
 
@@ -97,16 +106,19 @@ Start the standalone runtime once at application startup:
97
106
 
98
107
  ```ts
99
108
  import { Capacitor, type PluginListenerHandle } from '@capacitor/core';
100
- import { enableVerticalControlArea, IonicNativeUIShell } from '@rdlabo/ionic-theme-ios27/vertical-bars';
109
+ import { enableVerticalControlArea } from '@rdlabo/ionic-theme-ios27/vertical-bars';
110
+ import { Foldable } from '@erkamyaman/capacitor-foldable';
101
111
 
102
112
  // Start on Chrome too; the Web projection stays idle until the class is present.
103
113
  const rail = await enableVerticalControlArea();
104
114
  let layoutListener: PluginListenerHandle | undefined;
105
115
 
106
116
  if (Capacitor.getPlatform() === 'ios') {
107
- // The runtime already monitors device layout; only subscribe.
108
- layoutListener = await IonicNativeUIShell.addListener('deviceLayoutChange', ({ placement }) => rail.setPlacement(placement));
109
- rail.setPlacement((await IonicNativeUIShell.getDeviceLayout()).placement);
117
+ layoutListener = await Foldable.addListener('barPlacementChange', ({ verticalBarEdge, inset }) =>
118
+ rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset }),
119
+ );
120
+ const { verticalBarEdge, inset } = await Foldable.getBarPlacement();
121
+ rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset });
110
122
  }
111
123
 
112
124
  // Call when the application owner is disposed.
@@ -118,15 +130,45 @@ const stopVerticalArea = async () => {
118
130
 
119
131
  `setPlacement` on the handle and the exported `setVerticalControlAreaPlacement` are the same function; either applies the application's chosen placement to the CSS layout and both projections. It requires a mounted `ion-app` — call it after the app root exists.
120
132
 
121
- - Pass the `placement` object from `getDeviceLayout()`/`deviceLayoutChange`, or just a logical edge: `'leading'` or `'trailing'`. The logical edge resolves to a physical side through the nearest `dir` attribute, or through an explicit `rtl` second argument when the app already knows its direction.
133
+ - Pass `{ edge, nativeEdge }`: `edge` is the application's chosen logical edge; `nativeEdge` is `verticalBarEdge` from `Foldable.getBarPlacement()`/`barPlacementChange`. They resolve through the nearest `dir` attribute, or the explicit `rtl` argument.
122
134
  - Pass `null` to restore the ordinary layout.
123
- - The device-layout listener reports what iOS chose; the application decides whether to apply it. An app that wants a fixed edge regardless of the report can simply pass its own `'leading'` or `'trailing'`.
135
+ - The device-layout listener reports what iOS chose; the application decides whether to apply it. The theme compares the application's chosen edge with its supplied `nativeEdge`; a mismatch uses the Web rail until the edges match again. For a fixed right-in-LTR rail, pass `{ edge: 'trailing', nativeEdge: verticalBarEdge }` on each `Foldable` update.
136
+
137
+ Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` — not both. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. If the app already uses `enableNativeUIShell()`, keep that single runtime and call `setVerticalControlAreaPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset })` from its listener.
138
+
139
+ On supported iOS versions the runtime hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar; on Web, Android, or when native projection is unavailable, Web clones remain the fallback. Back navigation can come from outside a fixed toolbar; menu buttons and other toolbar actions still require one.
140
+
141
+ ### Toolbar actions
142
+
143
+ An `ion-button` moves into the rail when it contains an `ion-icon` or SVG with `slot="icon-only"`. The button must be in a fixed `ion-toolbar` directly inside `ion-header` or `ion-footer`, outside scrolling `ion-content`.
144
+
145
+ | Icon markup | Placement |
146
+ | --- | --- |
147
+ | `slot="icon-only"` | Vertical rail |
148
+ | `slot="start"`, `slot="end"`, or no slot | Original horizontal toolbar |
149
+ | No icon | Original horizontal toolbar |
150
+
151
+ This rule applies to `fill="default"`, `clear`, `solid`, and `outline`, including buttons with an Ionic `color`. Solid actions retain their background color through a prominent native button; outline actions retain their border color and width. The runtime preserves the original click or form-submit behavior. `type="submit"` and `.button-submit` do not select a different placement.
152
+
153
+ ```html
154
+ <ion-header>
155
+ <ion-toolbar>
156
+ <ion-buttons slot="end">
157
+ <ion-button type="button" fill="outline" color="primary" aria-label="Done">
158
+ <ion-icon name="checkmark-outline" slot="icon-only"></ion-icon>
159
+ </ion-button>
160
+ </ion-buttons>
161
+ </ion-toolbar>
162
+ </ion-header>
163
+ ```
164
+
165
+ Give icon-only actions an accessible name, such as `aria-label="Done"`. Keep the original Ionic event handler or form association on the source button.
124
166
 
125
- Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` — not both. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. If the app already uses `enableNativeUIShell()`, keep that single runtime and call `setVerticalControlAreaPlacement(placement)` from its listener.
167
+ The same rule applies to individual buttons and buttons inside `ion-buttons`, on ordinary pages and in the topmost full-width modal. Centered modals, menus, and popovers keep their own toolbar layout. Add `.ios-theme-horizontal-only` to a group or individual button to keep it horizontal. Placement is chosen when a routed page enters; changing an existing button's content or icon slot does not move it between the toolbar and rail until the page leaves and re-enters.
126
168
 
127
- On supported iOS versions the runtime hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar; on Web, Android, or when native projection is unavailable, Web clones remain the fallback. Back navigation can come from outside a fixed toolbar; other actions still require one. Fixed-toolbar actions need an icon or SVG, no direct text node, and standard `fill="default"` or `fill="clear"` — text-only actions stay in the original Web toolbar. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the horizontal toolbar. Placement is chosen when a routed page enters; changing an existing button's content does not move it between the toolbar and rail until the page leaves and re-enters. Menus, modals, and popovers retain their own toolbar layout.
169
+ ### Tab bar
128
170
 
129
- When the app contains `ion-tabs`, its tab bar moves into the reserved region and aligns above the bottom safe area; the Ionic `slot` value does not select a different position. Without native projection, the stable Web rail is icon-only, matching a four-tab SwiftUI `TabView`. While the user presses and drags across that rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The Web tab bar receives pointer input in the simulated system region. Use `ion-menu` when navigation should become a sidebar; this mode does not convert tabs into a menu. Web clones also work when no `ion-tabs` exists. Disabling the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override `--ios-theme-vertical-bars-toolbar-top` when the simulated system controls use a different vertical layout.
171
+ When the app contains `ion-tabs`, its tab bar moves into the reserved region and uses the native Duo edge spacing; the Ionic `slot` value does not select a different position. Without native projection, the stable Web rail is icon-only, matching the native resting presentation. While the user presses and drags across that rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The Web tab bar receives pointer input in the simulated system region. Native tabs and a restored Web tab bar fade in over 180ms; disappearance remains immediate. Reduced motion disables this fade. Use `ion-menu` when navigation should become a sidebar; this mode does not convert tabs into a menu. Web clones also work when no `ion-tabs` exists. Disabling the mode or leaving the page removes the native ownership or Web clones and restores their sources. Override `--ios-theme-vertical-bars-toolbar-top` when the simulated system controls use a different vertical layout.
130
172
 
131
173
  ## Adapt the split pane
132
174
 
@@ -154,7 +196,7 @@ ion-split-pane {
154
196
  }
155
197
  ```
156
198
 
157
- The registered `--ios-theme-split-pane-width` defaults to `320px`; `.ios-theme-split-pane-half-open` sets it to `50vw`. Set `halfOpened` when `deviceLayoutChange` reports `HingeStatus.PartiallyOpen` (and read the initial value with `getDeviceLayout`). Ionic's `when` decides whether the menu is a persistent side pane; choose its breakpoint so the pane is hidden when closed — `null` means the device has no hinge, so restore the ordinary breakpoint for it. The application chooses where to apply this width rule; an ordinary split pane elsewhere is unchanged. This layout does not enable Vertical Bars or move an overlay menu.
199
+ The registered `--ios-theme-split-pane-width` defaults to `320px`; `.ios-theme-split-pane-half-open` sets it to `50vw`. Set `halfOpened` when `foldStateChange` reports `state === 'half-opened'` (and read the initial value with `getFoldState()`). Ionic's `when` decides whether the menu is a persistent side pane; use the 900px breakpoint for a half-opened state or a flat state with hinge geometry, and the ordinary 992px breakpoint when closed or flat without geometry. Missing `hingeBounds` alone does not mean the device is flat. The application chooses where to apply this width rule; an ordinary split pane elsewhere is unchanged. This layout does not enable Vertical Bars or move an overlay menu.
158
200
 
159
201
  ## Vertical Control Area API
160
202
 
@@ -232,10 +274,11 @@ Stops synchronization, restores Web controls and releases native resources.
232
274
 
233
275
  #### VerticalBarPlacement
234
276
 
235
- | Prop | Type | Description |
236
- | ----------- | ----------------------------------------------------------- | ---------------------------------------------------------- |
237
- | **`edge`** | <code><a href="#verticalbaredge">VerticalBarEdge</a></code> | |
238
- | **`inset`** | <code>number</code> | UIKit safe-area inset on the vertical-bar edge, in points. |
277
+ | Prop | Type | Description |
278
+ | ---------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
279
+ | **`edge`** | <code><a href="#verticalbaredge">VerticalBarEdge</a></code> | |
280
+ | **`inset`** | <code>number</code> | Explicit rail width in CSS pixels; omitted to use the stylesheet's safe-area rules. |
281
+ | **`nativeEdge`** | <code><a href="#verticalbaredge">VerticalBarEdge</a></code> | 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. |
239
282
 
240
283
 
241
284
  #### NativeUIShellStatus
package/docs/migration.md CHANGED
@@ -8,8 +8,8 @@ title: Migration
8
8
 
9
9
  Use `withNativeUIShellTransition()` to keep your existing Ionic navigation animation while coordinating Native UI Shell controls.
10
10
 
11
- - If you already use this package's `iosTransitionAnimation`, no configuration change is needed. It now uses the shared adapter internally; do not add another wrapper.
12
- - If you use Ionic's default animation without a `navAnimation` option, follow [Keep Ionic's default animation](./iphone-duo-with-original-theme.md#keep-ionics-default-animation). The example selects Ionic's standard iOS or MD builder from the transition mode.
11
+ - If you already import `iosTransitionAnimation` from `@rdlabo/ionic-theme-ios27`, keep the [package animation setup](./iphone-duo-with-original-theme.md#use-this-package%27s-ios-animation); no configuration change is needed. It now uses the shared adapter internally; do not add another wrapper.
12
+ - If you use Ionic's default animation without a `navAnimation` option, follow [Keep Ionic's default animation](./iphone-duo-with-original-theme.md#keep-ionic%27s-default-animation). The example selects Ionic's standard iOS or MD builder from the transition mode.
13
13
  - If you use a custom navigation animation with Native UI Shell or the standalone Vertical Control Area, wrap your existing builder when configuring Ionic:
14
14
 
15
15
  ```diff
@@ -25,7 +25,7 @@ Merge this option into your existing Ionic configuration before initialization.
25
25
 
26
26
  Use the adapter only for navigation; leave modal and popover animations unchanged. Your builder must create a fresh `Animation` for each navigation because Ionic destroys it afterward. Keep lifecycle events for control registration and transitions without animation. If the custom builder animates a horizontal back button separately, exclude that effect while `.ios-theme-vertical-bars` is active.
27
27
 
28
- See [Connect your navigation animation](./iphone-duo-with-original-theme.md#3-connect-your-navigation-animation) for the setup and supported scope.
28
+ See [Connect your navigation animation](./iphone-duo-with-original-theme.md#3.-connect-your-navigation-animation) for the setup and supported scope.
29
29
 
30
30
  ## From the iOS 26 theme
31
31
 
@@ -40,6 +40,8 @@ Native appearance follows the applied class, system or always-dark theme CSS. Sy
40
40
 
41
41
  ## Supported markup
42
42
 
43
+ The table below describes the ordinary Native UI Shell. Vertical Bars uses the separate [toolbar action rules](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo#toolbar-actions).
44
+
43
45
  | Ionic component | Supported appearance and placement | Native rendering |
44
46
  | --------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
45
47
  | `ion-button` | `fill="default"`, standard glass, in a fixed header/footer toolbar | Glass `UIButton` |
@@ -62,11 +64,11 @@ Use `ios-theme-shell-disabled` to disable only the iOS Native UI Shell while kee
62
64
 
63
65
  If a child inside a shared native surface opts out, the entire surface stays on the Web: this includes button groups, tab bars, segments and FAB lists. Opting out of the search FAB or any part of the search footer disables native search integration; the tab bar can still render natively if it remains eligible.
64
66
 
65
- Placement is required even when the appearance is glass. In the ordinary Native UI Shell, buttons, back buttons, menu-button groups and segments need a toolbar directly inside `ion-header` or `ion-footer`, with no `ion-content` ancestor around the control. Buttons directly inside a header/footer, standalone toolbars, and toolbars or headers nested in scrolling content stay on the Web. FABs without `slot="fixed"` also stay on the Web. Moving a projected control to an excluded location restores its Web rendering; moving it back re-evaluates eligibility. When `.ios-theme-vertical-bars` is enabled, a standard `ion-back-button` can instead be projected to the Vertical Control Area from outside a fixed toolbar, including routed content or a persistent app shell. The application chooses where to enable this mode and which Ionic component mode to use; Vertical Bars projection does not require `ios` mode classes. Overlays, collapsed headers, opted-out controls and departed pages are excluded.
67
+ Placement is required even when the appearance is glass. In the ordinary Native UI Shell, buttons, back buttons, menu-button groups and segments need a toolbar directly inside `ion-header` or `ion-footer`, with no `ion-content` ancestor around the control. Buttons directly inside a header/footer, standalone toolbars, and toolbars or headers nested in scrolling content stay on the Web. FABs without `slot="fixed"` also stay on the Web. Moving a projected control to an excluded location restores its Web rendering; moving it back re-evaluates eligibility. When `.ios-theme-vertical-bars` is enabled, a standard `ion-back-button` can instead be projected to the Vertical Control Area from outside a fixed toolbar, including routed content or a persistent app shell. The application chooses where to enable this mode and which Ionic component mode to use; Vertical Bars projection does not require `ios` mode classes. Collapsed headers, opted-out controls and departed pages are excluded. Full-width foreground modals can participate in Vertical Bars as described below; other overlay surfaces keep their own layout.
66
68
 
67
69
  Native tabs accept equal-width items with Ionic's default `layout="icon-top"`. The native bar uses a local compact horizontal and regular vertical size class to preserve the Web's stacked icon/label arrangement on iPad and in landscape. This does not change the app's size class. Label size and weight follow the Web snapshot. Other explicit Ionic layouts (`icon-start`, `icon-end`, `icon-bottom`, `icon-hide`, `label-hide`) and unequal item widths keep the entire tab bar on the Web. Start, center and end placement follow the original `ion-tab-bar`, including RTL. Directional `ion-icon` artwork preserves its rendered RTL flip.
68
70
 
69
- Standalone clear, solid and outline buttons are excluded. A glass `ion-buttons` group of two or more clear buttons is projected as one surface; its children retain separate actions. Menu buttons can also share that group. A single menu button uses its parent `ion-buttons` as the glass surface, so no Web glass is left underneath the native button. A menu button outside this theme glass remains on the Web. Mixed fills, unsupported children, or a theme-disabled child keep the group on the Web. Single clear buttons remain on the Web. Custom button colors, custom back icons/colors, collapsing headers, toolbars inside scrolling content, modal content, scrollable/expanded segments and segment-view integration remain on the Web. Complex slots and unsupported SVG features also fall back to Web. The plugin does not translate arbitrary application CSS into UIKit styles.
71
+ Outside Vertical Bars, standalone clear, solid and outline buttons are excluded. A glass `ion-buttons` group of two or more clear buttons is projected as one surface; its children retain separate actions. Menu buttons can also share that group. A single menu button uses its parent `ion-buttons` as the glass surface, so no Web glass is left underneath the native button. A menu button outside this theme glass remains on the Web. Mixed fills, unsupported children, or a theme-disabled child keep the group on the Web. Single clear buttons remain on the Web. Custom button colors, custom back icons/colors, collapsing headers, toolbars inside scrolling content, modal content, scrollable/expanded segments and segment-view integration remain on the Web. Complex slots and unsupported SVG features also fall back to Web. The plugin does not translate arbitrary application CSS into UIKit styles.
70
72
 
71
73
  Native glass samples the Web content actually drawn behind it. Existing toolbar backgrounds and header blur still affect that content. For content to scroll beneath a header, use Ionic's normal translucent-header/fullscreen-content layout; the plugin does not move page content or override an application-owned opaque toolbar background.
72
74
 
@@ -88,13 +90,15 @@ The native host accepts input only within native controls. Tab interaction and a
88
90
 
89
91
  ## Searchable tabs
90
92
 
91
- Existing `attachTabBarSearchable(tabBar, fabButton, footer)` registrations automatically use native search when their bottom tab bar and glass search controls are supported. No new component option, route, native setup or page listener is required. Ordinary tab bars continue using `UITabBar`; searchable groups use a persistent `UITabBarController`, `UITab` / `UISearchTab`, and `UISearchController`. The original Capacitor WebView continues to render results and handle navigation.
93
+ Existing `attachTabBarSearchable(tabBar, fabButton, footer)` registrations automatically use native search when their bottom tab bar and glass search controls are supported. No new component option, route, native setup or page listener is required. Ordinary tab bars continue using `UITabBar`; horizontal searchable groups use a persistent `UITabBarController`, `UITab` / `UISearchTab`, and `UISearchController`. The original Capacitor WebView continues to render results and handle navigation.
94
+
95
+ With vertical bars, the native rail keeps every tab and its selection. SwiftUI’s `searchable` and `searchToolbarBehavior(.minimize)` provide the system search button, field, dismissal control and transitions within the same native navigation surface. Apple determines their placement for the current device layout and focuses the native field when system search opens. Input, focus, clear and submit events use the same bridge as horizontal search. When native search is unavailable, the Web search presentation also preserves vertical tabs.
92
96
 
93
97
  Search registration does not bypass placement restrictions. Its searchbar and close button must be in fixed footer toolbars. Its trigger must belong to an `ion-fab[slot="fixed"]` directly inside `ion-content`, or directly on the existing non-scrolling `.ion-page` layout. A wrapper inside scrolling content is not a fixed slot.
94
98
 
95
- While a registration is alive, Native UI Shell keeps the searchable controller even when the page is transitioning or temporarily unavailable (`available: false`), instead of demoting back to a separate ordinary control identity. Resting chrome (including Album before search opens) uses the same `UITabBar` + `ShellTabBar.fit` path as ordinary tabs, with the search trigger pinned to the FAB when available; `UISearchTab` is shown only while the search session is active. Flipping availability or entering/leaving search crossfades between those layers. Register before the destination page finishes entering (for example in `ionViewWillEnter`) so the first visit does not paint ordinary tabs and then swap.
99
+ For horizontal tabs, while a registration is alive, Native UI Shell keeps the searchable controller even when the page is transitioning or temporarily unavailable (`available: false`), instead of demoting back to a separate ordinary control identity. Resting chrome (including Album before search opens) uses the same `UITabBar` + `ShellTabBar.fit` path as ordinary tabs, with the search trigger pinned to the FAB when available; `UISearchTab` is shown only while the search session is active. Flipping availability or entering/leaving search crossfades between those layers. Register before the destination page finishes entering (for example in `ionViewWillEnter`) so the first visit does not paint ordinary tabs and then swap.
96
100
 
97
- Opening search preserves the selected Ionic tab and does not automatically show the keyboard (`automaticallyActivatesSearch` stays off). Tap the field or call `ion-searchbar.setFocus()` for the keyboard. While search is active, Native UI Shell freezes Web layout projection and holds Capacitor Keyboard resize at `none`; UIKit owns tab/search chrome (no Ionic `fit` mid-session). Leave re-fits ordinary tabs to `ion-tab-bar` and search to the FAB. Ordinary native tabs keep an optimistic selection until the Web `selected` state catches up. Input events and application `value` updates continue across the bridge until search closes. The trigger's resolved SVG and the search icon are projected from Ionic, including `ion-icon name`.
101
+ With horizontal tabs, opening search preserves the selected Ionic tab and does not automatically show the keyboard (`automaticallyActivatesSearch` stays off). Tap the field or call `ion-searchbar.setFocus()` for the keyboard. While search is active, Native UI Shell freezes Web layout projection and holds Capacitor Keyboard resize at `none`; UIKit owns tab/search chrome (no Ionic `fit` mid-session). Leave re-fits ordinary tabs to `ion-tab-bar` and search to the FAB. Ordinary native tabs keep an optimistic selection until the Web `selected` state catches up. Input events and application `value` updates continue across the bridge until search closes. The trigger's resolved SVG and the search icon are projected from Ionic, including `ion-icon name`.
98
102
 
99
103
  Native edits pass through Ionic's input handlers, preserving `ionInput` debounce, `ionChange`, `ionFocus`, `ionBlur`, and `ionClear`. Programmatic `value` changes do not emit `ionInput`; synchronous application corrections and stale native input are distinguished. Native editing owns marked text and the caret. Returning through the footer's close action retains the value and does not emit `ionCancel` or `ionClear`.
100
104
 
@@ -116,7 +120,7 @@ FAB support covers the standard circular glass appearance, text and resolved sta
116
120
 
117
121
  Custom host animation or transition declarations on the FAB, list or button keep the group on the Web until removed. Button transforms support the standard identity and hidden-child scale(0), not custom scaling. For children inside a `display:none` list, browsers can report a computed transform of `none` even when a custom transform is declared. Such transforms are checked when layout becomes available; the whole FAB then returns to Web if needed. The plugin does not parse application stylesheets or temporarily open lists to predict hidden layout.
118
122
 
119
- The built-in `iosTransitionAnimation` waits for native retirement before starting the Web animation. Interactive progress and completion/cancellation are queued while that retirement is in progress. Stationary shared tabs are retained. First render and transitions without an animation builder are covered by the startup runtime and Ionic lifecycle events. Default Ionic and custom navigation builders can use the same integration through `withNativeUIShellTransition()`, exported from both the package root and `/vertical-bars`. See [Connect your navigation animation](./iphone-duo-with-original-theme.md#3-connect-your-navigation-animation) for the setup.
123
+ The built-in `iosTransitionAnimation` waits for native retirement before starting the Web animation. Interactive progress and completion/cancellation are queued while that retirement is in progress. Stationary shared tabs are retained. First render and transitions without an animation builder are covered by the startup runtime and Ionic lifecycle events. Default Ionic and custom navigation builders can use the same integration through `withNativeUIShellTransition()`, exported from both the package root and `/vertical-bars`. See [Connect your navigation animation](./iphone-duo-with-original-theme.md#3.-connect-your-navigation-animation) for the setup.
120
124
 
121
125
  Tab switches skip the Web/native crossfade so a retiring UIKit snapshot cannot linger over the next tab. Detection uses the router URL versus the still-selected tab on `ionViewWillLeave` (and vanilla `ionTabsWillChange` / `ionTabsDidChange` DOM events). Stack pushes and pops keep the normal 180ms handoff.
122
126
 
@@ -175,9 +179,9 @@ The standalone Vertical Control Area entry point (`@rdlabo/ionic-theme-ios27/ver
175
179
 
176
180
  On supported iOS versions, adding `.ios-theme-vertical-bars` changes only controls that the system relocates into the physical side rail. Native UI Shell presents eligible tabs, back navigation, menu buttons, and toolbar actions through a SwiftUI `TabView` and toolbar once the class is applied. When the OS reports a rail edge — on iPhone Duo linked against iOS 27.1 or later — it must agree with the applied placement; a disagreeing report keeps the rail on the Web. Older toolchains that cannot report an edge trust the DOM placement directly. SwiftUI owns their adaptive placement and Liquid Glass appearance; Ionic remains the source of labels, icons, selected/disabled state, routing, form submission, and click handlers.
177
181
 
178
- The SwiftUI surface is clipped and hit-tested to the system rail. Web content remains visible and interactive outside that physical region. The runtime optimistically updates tab selection before forwarding the action to the original `ion-tab-button`, using the same event and stale-revision protection as the other native controls. Menus, modals, and popovers remain independent surfaces and are not moved into the main-page rail.
182
+ The SwiftUI surface is clipped and hit-tested to the system rail. Web content remains visible and interactive outside that physical region. The runtime optimistically updates tab selection before forwarding the action to the original `ion-tab-button`, using the same event and stale-revision protection as the other native controls. A topmost full-width modal supplies its own eligible toolbar actions and rail bounds, including when it is a sheet. Centered modals, menus, and popovers keep their own toolbar layout. Modal placement follows the visible dialog width rather than its type or hinge posture.
179
183
 
180
- This mode accepts standard `fill="default"` and `fill="clear"` fixed-toolbar buttons because SwiftUI determines their compact representation. Solid, outline, custom-color, scrolling, and explicitly opted-out controls remain on the Web. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the horizontal Web toolbar. On Web, Android, older iOS, or when native projection is unavailable during setup, the Web projection remains the fallback.
184
+ In Vertical Bars, fixed-toolbar `ion-button` actions use an `ion-icon` or SVG with `slot="icon-only"`. All fills (`default`, `clear`, `solid`, and `outline`) and Ionic colors are eligible; submit buttons follow the same placement rule. Solid actions retain their background color and outline actions retain their border color and width. Actions without `icon-only` stay horizontal. Scrolling and explicitly opted-out controls retain their Web presentation. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the horizontal Web toolbar. On Web, Android, older iOS, or when native projection is unavailable during setup, the Web projection remains the fallback.
181
185
 
182
186
  For standalone setup while keeping your existing theme, see [iPhone Duo with your existing theme](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo-with-original-theme).
183
187
 
@@ -166,19 +166,6 @@ The example uses Ionic's standard collapsible large-title structure. Scroll the
166
166
 
167
167
  The `.ion-page` wrapper makes this embedded preview behave like a complete routed page. An application using `ion-router-outlet` normally receives that page container automatically. The inset list and its items only provide enough content to demonstrate scrolling; they are not required by `.searchbar-classic`.
168
168
 
169
- ## Search-bar toolbars
170
-
171
- Add `.toolbar-searchbar` when an `ion-toolbar` combines a search bar with start or end buttons. The class centers the slotted controls and adjusts the spacing around the search field.
172
-
173
- ```html preview
174
- <ion-toolbar class="toolbar-searchbar">
175
- <ion-buttons slot="start">
176
- <ion-button>Cancel</ion-button>
177
- </ion-buttons>
178
- <ion-searchbar></ion-searchbar>
179
- </ion-toolbar>
180
- ```
181
-
182
169
  ## Opting out
183
170
 
184
171
  Add `.ios-theme-disabled` to an individual Ionic component when it must retain Ionic's standard iOS styling.
@@ -35,7 +35,7 @@ final class ShellSearchHost: UIView {
35
35
  }
36
36
 
37
37
  // Preserve UISearchBar's delegate while returning its clear action to Ionic.
38
- private final class ShellSearchInputDelegate: NSObject, UITextFieldDelegate {
38
+ final class ShellSearchInputDelegate: NSObject, UITextFieldDelegate {
39
39
  weak var original: UITextFieldDelegate?
40
40
  var clear: (() -> Void)?
41
41
  override func responds(to selector: Selector!) -> Bool {