@rdlabo/ionic-theme-ios27 1.1.0 → 1.2.0-0

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 (93) hide show
  1. package/README.md +8 -1
  2. package/dist/css/components/ion-list.css +1 -1
  3. package/dist/css/components/ion-menu.css +1 -1
  4. package/dist/css/ionic-theme-ios27.css +1 -1
  5. package/dist/css/vertical-bars.css +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +15 -8
  8. package/dist/native/components/index.d.ts +1 -0
  9. package/dist/native/components/index.d.ts.map +1 -1
  10. package/dist/native/components/index.js +8 -3
  11. package/dist/native/components/ion-back-button.d.ts.map +1 -1
  12. package/dist/native/components/ion-back-button.js +5 -2
  13. package/dist/native/components/ion-button.d.ts.map +1 -1
  14. package/dist/native/components/ion-button.js +10 -2
  15. package/dist/native/components/ion-buttons.d.ts.map +1 -1
  16. package/dist/native/components/ion-buttons.js +12 -4
  17. package/dist/native/components/ion-fab.js +4 -4
  18. package/dist/native/components/ion-menu-button.d.ts.map +1 -1
  19. package/dist/native/components/ion-menu-button.js +5 -4
  20. package/dist/native/components/searchable-tabs.d.ts.map +1 -1
  21. package/dist/native/components/searchable-tabs.js +4 -1
  22. package/dist/native/definitions.d.ts +29 -3
  23. package/dist/native/definitions.d.ts.map +1 -1
  24. package/dist/native/definitions.js +6 -1
  25. package/dist/native/index.d.ts +6 -2
  26. package/dist/native/index.d.ts.map +1 -1
  27. package/dist/native/index.js +154 -17
  28. package/dist/native/prehide.d.ts +5 -0
  29. package/dist/native/prehide.d.ts.map +1 -0
  30. package/dist/native/prehide.js +246 -0
  31. package/dist/native/runtime.d.ts +1 -1
  32. package/dist/native/runtime.d.ts.map +1 -1
  33. package/dist/native/runtime.js +162 -28
  34. package/dist/native/shared/candidate.d.ts.map +1 -1
  35. package/dist/native/shared/candidate.js +7 -3
  36. package/dist/native/shared/dom.d.ts +33 -2
  37. package/dist/native/shared/dom.d.ts.map +1 -1
  38. package/dist/native/shared/dom.js +171 -10
  39. package/dist/native/vertical-bars-web.d.ts +3 -0
  40. package/dist/native/vertical-bars-web.d.ts.map +1 -0
  41. package/dist/native/vertical-bars-web.js +341 -0
  42. package/dist/native-integration/index.d.ts +3 -2
  43. package/dist/native-integration/index.d.ts.map +1 -1
  44. package/dist/native-integration/index.js +11 -5
  45. package/dist/transition/index.d.ts +2 -2
  46. package/dist/transition/index.d.ts.map +1 -1
  47. package/dist/transition/index.js +1 -1
  48. package/dist/transition/ios.transition.d.ts.map +1 -1
  49. package/dist/transition/ios.transition.js +1 -0
  50. package/dist/utils.d.ts +3 -3
  51. package/dist/utils.d.ts.map +1 -1
  52. package/dist/utils.js +1 -1
  53. package/dist/vertical-bars.d.ts +4 -0
  54. package/dist/vertical-bars.d.ts.map +1 -0
  55. package/dist/vertical-bars.js +2 -0
  56. package/docs/iphone-duo.md +255 -0
  57. package/docs/migration.md +2 -2
  58. package/docs/native-ui-shell.md +15 -3
  59. package/docs/special-markup.md +6 -0
  60. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +15 -1
  61. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +358 -0
  62. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +215 -35
  63. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +10 -1
  64. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +29 -0
  65. package/package.json +6 -2
  66. package/src/index.ts +15 -8
  67. package/src/native/components/index.ts +12 -3
  68. package/src/native/components/ion-back-button.ts +8 -2
  69. package/src/native/components/ion-button.ts +12 -2
  70. package/src/native/components/ion-buttons.ts +9 -4
  71. package/src/native/components/ion-fab.ts +4 -4
  72. package/src/native/components/ion-menu-button.ts +5 -4
  73. package/src/native/components/searchable-tabs.ts +9 -1
  74. package/src/native/definitions.ts +40 -3
  75. package/src/native/index.ts +188 -20
  76. package/src/native/prehide.ts +255 -0
  77. package/src/native/runtime.ts +173 -27
  78. package/src/native/shared/candidate.ts +11 -3
  79. package/src/native/shared/dom.ts +212 -11
  80. package/src/native/vertical-bars-web.ts +374 -0
  81. package/src/native-integration/index.ts +13 -8
  82. package/src/styles/components/ion-list.scss +2 -2
  83. package/src/styles/components/ion-menu.scss +2 -2
  84. package/src/styles/ionic-theme-ios27.scss +1 -0
  85. package/src/styles/vertical-bars.scss +342 -0
  86. package/src/transition/index.ts +11 -6
  87. package/src/transition/ios.transition.ts +1 -0
  88. package/src/utils.ts +8 -8
  89. package/src/vertical-bars.ts +3 -0
  90. package/dist/native/lifecycle.d.ts +0 -4
  91. package/dist/native/lifecycle.d.ts.map +0 -1
  92. package/dist/native/lifecycle.js +0 -34
  93. package/src/native/lifecycle.ts +0 -38
@@ -0,0 +1,358 @@
1
+ import Combine
2
+ import SwiftUI
3
+ import UIKit
4
+
5
+ protocol ShellVerticalBarsControlling: AnyObject {
6
+ var view: UIView { get }
7
+ func attach(to owner: UIViewController, in parent: UIView)
8
+ func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String)
9
+ func detach()
10
+ }
11
+
12
+ @available(iOS 26.0, *)
13
+ final class ShellVerticalBarsModel: ObservableObject {
14
+ struct Item: Identifiable {
15
+ let id: String
16
+ let label: String
17
+ let accessibilityLabel: String
18
+ let image: UIImage?
19
+ let badge: ShellBadge?
20
+ let disabled: Bool
21
+ var selected: Bool
22
+ }
23
+
24
+ struct Group: Identifiable {
25
+ let id: String
26
+ let items: [Item]
27
+ let slot: ShellControl.ToolbarSlot?
28
+ }
29
+
30
+ @Published var back: Item?
31
+ @Published var groups: [Group] = []
32
+ @Published var tabs: [Item] = []
33
+ @Published var selection = ""
34
+ var activate: (String) -> Void = { _ in }
35
+ private var domSelection = ""
36
+ private var pendingSelection: ShellTabBar.PendingSelection?
37
+ private var pendingExpiryWork: DispatchWorkItem?
38
+
39
+ func apply(_ controls: [ShellControl], rendering: ShellRendering,
40
+ now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()) {
41
+ func item(_ source: ShellItem) -> Item {
42
+ Item(id: source.id, label: source.content.label,
43
+ accessibilityLabel: source.content.accessibilityLabel,
44
+ image: rendering.image(source.content), badge: source.content.badge, disabled: source.content.disabled,
45
+ selected: source.content.selected)
46
+ }
47
+ withAnimation(.smooth(duration: 0.3)) {
48
+ back = controls.first(where: { $0.kind == .backButton })?.items.first.map(item)
49
+ groups = controls.compactMap { control in
50
+ guard [.button, .buttons, .menuButton].contains(control.kind) else { return nil }
51
+ return Group(id: control.id, items: control.items.map(item), slot: control.toolbarSlot)
52
+ }
53
+ }
54
+ tabs = controls.first(where: { $0.kind == .tabBar })?.items.map(item) ?? []
55
+ domSelection = tabs.first(where: \.selected)?.id ?? ""
56
+ reconcileSelection(now: now)
57
+ }
58
+
59
+ func select(_ id: String, now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent(), ttl: CFTimeInterval = 1) {
60
+ guard let item = tabs.first(where: { $0.id == id }), !item.disabled else { return }
61
+ pendingSelection = .init(id: id, until: now + ttl)
62
+ selection = id
63
+ pendingExpiryWork?.cancel()
64
+ let work = DispatchWorkItem { [weak self] in self?.reconcileSelection() }
65
+ pendingExpiryWork = work
66
+ DispatchQueue.main.asyncAfter(deadline: .now() + ttl + 0.02, execute: work)
67
+ activate(id)
68
+ }
69
+
70
+ private func reconcileSelection(now: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()) {
71
+ guard let pending = pendingSelection else { selection = domSelection; return }
72
+ let item = tabs.first(where: { $0.id == pending.id })
73
+ if item == nil || item!.disabled || now >= pending.until || domSelection == pending.id {
74
+ pendingSelection = nil
75
+ pendingExpiryWork?.cancel()
76
+ pendingExpiryWork = nil
77
+ selection = domSelection
78
+ } else {
79
+ selection = pending.id
80
+ }
81
+ }
82
+ }
83
+
84
+ @available(iOS 26.0, *)
85
+ private struct ShellVerticalBarsLabel: View {
86
+ let item: ShellVerticalBarsModel.Item
87
+
88
+ var body: some View {
89
+ if let image = item.image {
90
+ Label {
91
+ Text(item.label.isEmpty ? item.accessibilityLabel : item.label)
92
+ } icon: {
93
+ Image(uiImage: image)
94
+ }
95
+ } else {
96
+ Text(item.label)
97
+ }
98
+ }
99
+ }
100
+
101
+ @available(iOS 26.0, *)
102
+ private func verticalBarsButton(_ item: ShellVerticalBarsModel.Item, model: ShellVerticalBarsModel) -> some View {
103
+ Button { model.activate(item.id) } label: { ShellVerticalBarsLabel(item: item) }
104
+ .disabled(item.disabled)
105
+ .accessibilityLabel(item.accessibilityLabel)
106
+ .accessibilityIdentifier(item.id)
107
+ }
108
+
109
+ @available(iOS 26.0, *)
110
+ private func verticalBarsBackButton(_ item: ShellVerticalBarsModel.Item, model: ShellVerticalBarsModel) -> some View {
111
+ Button { model.activate(item.id) } label: { Image(systemName: "chevron.backward") }
112
+ .disabled(item.disabled)
113
+ .accessibilityLabel(item.accessibilityLabel)
114
+ .accessibilityIdentifier("BackButton")
115
+ }
116
+
117
+ @available(iOS 26.0, *)
118
+ private struct ShellVerticalBarsBadge: ViewModifier {
119
+ let badge: ShellBadge?
120
+
121
+ @ViewBuilder func body(content: Content) -> some View {
122
+ if let badge { content.badge(badge.value) }
123
+ else { content }
124
+ }
125
+ }
126
+
127
+ @available(iOS 26.0, *)
128
+ private struct ShellVerticalBarsView: View {
129
+ @ObservedObject var model: ShellVerticalBarsModel
130
+
131
+ var body: some View {
132
+ Group {
133
+ if model.tabs.isEmpty {
134
+ ShellVerticalBarsPage(model: model)
135
+ } else {
136
+ TabView(selection: Binding(get: { model.selection }, set: { model.select($0) })) {
137
+ ForEach(model.tabs) { item in
138
+ ShellVerticalBarsPage(model: model)
139
+ .tag(item.id)
140
+ .tabItem { ShellVerticalBarsLabel(item: item) }
141
+ .modifier(ShellVerticalBarsBadge(badge: item.badge))
142
+ .disabled(item.disabled)
143
+ .accessibilityLabel(item.accessibilityLabel)
144
+ .accessibilityIdentifier(item.id)
145
+ }
146
+ }
147
+ }
148
+ }
149
+ .modifier(ShellVerticalBarsCompression())
150
+ .background(Color.clear)
151
+ }
152
+ }
153
+
154
+ @available(iOS 26.0, *)
155
+ private struct ShellVerticalBarsPage: View {
156
+ @ObservedObject var model: ShellVerticalBarsModel
157
+
158
+ var body: some View {
159
+ NavigationStack {
160
+ Color.clear.modifier(ShellVerticalBarsToolbarAdapter(model: model))
161
+ }
162
+ }
163
+ }
164
+
165
+ @available(iOS 26.0, *)
166
+ private struct ShellVerticalBarsCompression: ViewModifier {
167
+ @ViewBuilder func body(content: Content) -> some View {
168
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
169
+ if #available(iOS 27.1, *) {
170
+ content.toolbarVerticalCompressionBehavior(.prefersToolbarItems)
171
+ } else {
172
+ content
173
+ }
174
+ #else
175
+ content
176
+ #endif
177
+ }
178
+ }
179
+
180
+ @available(iOS 26.0, *)
181
+ private struct ShellVerticalBarsToolbarAdapter: ViewModifier {
182
+ @ObservedObject var model: ShellVerticalBarsModel
183
+
184
+ @ViewBuilder func body(content: Content) -> some View {
185
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
186
+ if #available(iOS 27.1, *) {
187
+ content.modifier(ShellVerticalBarsToolbar(model: model))
188
+ } else {
189
+ content.modifier(ShellVerticalBarsLegacyToolbar(model: model))
190
+ }
191
+ #else
192
+ content.modifier(ShellVerticalBarsLegacyToolbar(model: model))
193
+ #endif
194
+ }
195
+ }
196
+
197
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
198
+ @available(iOS 27.1, *)
199
+ private struct ShellVerticalBarsToolbar: ViewModifier {
200
+ @ObservedObject var model: ShellVerticalBarsModel
201
+
202
+ func body(content: Content) -> some View {
203
+ content.toolbar {
204
+ if let back = model.back {
205
+ ToolbarItem(placement: .navigation) {
206
+ verticalBarsBackButton(back, model: model)
207
+ }
208
+ .axisBehavior(.verticalPreferred)
209
+ }
210
+ ForEach(model.groups.filter { $0.slot == .start }) { group in
211
+ ToolbarItemGroup(placement: .topBarLeading) {
212
+ ForEach(group.items) { item in
213
+ verticalBarsButton(item, model: model)
214
+ }
215
+ }
216
+ .axisBehavior(.verticalPreferred)
217
+ }
218
+ ForEach(model.groups.filter { $0.slot != .start }) { group in
219
+ ToolbarItemGroup(placement: .topBarTrailing) {
220
+ ForEach(group.items) { item in
221
+ verticalBarsButton(item, model: model)
222
+ }
223
+ }
224
+ .axisBehavior(.verticalPreferred)
225
+ }
226
+ }
227
+ }
228
+ }
229
+ #endif
230
+
231
+ @available(iOS 26.0, *)
232
+ private struct ShellVerticalBarsLegacyToolbar: ViewModifier {
233
+ @ObservedObject var model: ShellVerticalBarsModel
234
+
235
+ func body(content: Content) -> some View {
236
+ content.toolbar {
237
+ if let back = model.back {
238
+ ToolbarItem(placement: .navigation) {
239
+ verticalBarsBackButton(back, model: model)
240
+ }
241
+ }
242
+ ForEach(model.groups) { group in
243
+ ToolbarItemGroup(placement: .primaryAction) {
244
+ ForEach(group.items) { item in
245
+ verticalBarsButton(item, model: model)
246
+ }
247
+ }
248
+ }
249
+ }
250
+ }
251
+ }
252
+
253
+ @available(iOS 26.0, *)
254
+ final class ShellVerticalBarsController: ShellVerticalBarsControlling {
255
+ private final class TransparentHostingController<Content: View>: UIHostingController<Content> {
256
+ var railEdge = "right"
257
+ override func viewDidLayoutSubviews() {
258
+ super.viewDidLayoutSubviews()
259
+ makeFullSizeSurfacesTransparent(in: view)
260
+ }
261
+
262
+ private func makeFullSizeSurfacesTransparent(in surface: UIView) {
263
+ let frame = surface.convert(surface.bounds, to: view)
264
+ guard !(surface is UIVisualEffectView) else { return }
265
+ let coversHost = frame.insetBy(dx: -1, dy: -1).contains(view.bounds)
266
+ // SwiftUI may add an opaque backing behind the rail controls, sized to
267
+ // the rail or to an expanded tab bar. Keep that backing clear without
268
+ // touching the glass controls or materials.
269
+ let coversRail = frame.minY <= 1 && frame.maxY >= view.bounds.maxY - 1 &&
270
+ (railEdge == "left" ? frame.minX <= 1 : frame.maxX >= view.bounds.maxX - 1)
271
+ if coversHost || coversRail {
272
+ surface.backgroundColor = .clear
273
+ surface.isOpaque = false
274
+ }
275
+ surface.subviews.forEach(makeFullSizeSurfacesTransparent)
276
+ }
277
+ }
278
+
279
+ private final class RailContainer: UIView {
280
+ var railEdge = "right"
281
+
282
+ private var railWidth: CGFloat {
283
+ let inset = railEdge == "left" ? safeAreaInsets.left : safeAreaInsets.right
284
+ return inset > 0 ? inset : 80
285
+ }
286
+
287
+ // The rail is not masked: expanded rail content (a widened tab bar) is
288
+ // allowed to draw over the WebView. Inside the base rail touches behave
289
+ // as before; beyond it touches are captured only where they land inside
290
+ // an actual bar surface so empty overlap still belongs to the WebView.
291
+ // The SwiftUI hosting scaffold reports a full-size hosting view for
292
+ // every point, so the hit result cannot tell bar content from empty
293
+ // space — the bar frames decide instead.
294
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
295
+ let hit = super.hitTest(point, with: event)
296
+ let inRail = railEdge == "left" ? point.x <= railWidth : point.x >= bounds.maxX - railWidth
297
+ if inRail { return hit }
298
+ guard hit != nil, containsBarSurface(at: point) else { return nil }
299
+ return hit
300
+ }
301
+
302
+ private func containsBarSurface(at point: CGPoint) -> Bool {
303
+ containsBarSurface(in: self, at: point)
304
+ }
305
+
306
+ private func containsBarSurface(in view: UIView, at point: CGPoint) -> Bool {
307
+ guard !view.isHidden, view.alpha > 0.05 else { return false }
308
+ let name = NSStringFromClass(type(of: view))
309
+ if (name.contains("TabBar") || name.contains("Platter") || name.contains("Pocket") || name.contains("Sidebar")),
310
+ convert(view.bounds, from: view).contains(point) { return true }
311
+ return view.subviews.contains { containsBarSurface(in: $0, at: point) }
312
+ }
313
+ }
314
+
315
+ private let model = ShellVerticalBarsModel()
316
+ private lazy var controller = TransparentHostingController(rootView: ShellVerticalBarsView(model: model))
317
+ private let container = RailContainer()
318
+ private weak var owner: UIViewController?
319
+
320
+ var view: UIView { container }
321
+
322
+ init(activate: @escaping (String) -> Void) {
323
+ model.activate = activate
324
+ container.backgroundColor = .clear
325
+ controller.view.backgroundColor = .clear
326
+ controller.view.isOpaque = false
327
+ }
328
+
329
+ func attach(to owner: UIViewController, in parent: UIView) {
330
+ guard self.owner !== owner || container.superview !== parent else { return }
331
+ detach()
332
+ self.owner = owner
333
+ owner.addChild(controller)
334
+ container.frame = parent.bounds
335
+ container.autoresizingMask = [.flexibleWidth, .flexibleHeight]
336
+ controller.view.frame = container.bounds
337
+ controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
338
+ container.addSubview(controller.view)
339
+ parent.addSubview(container)
340
+ controller.didMove(toParent: owner)
341
+ }
342
+
343
+ func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) {
344
+ container.railEdge = edge
345
+ controller.railEdge = edge
346
+ model.apply(controls, rendering: rendering)
347
+ controller.overrideUserInterfaceStyle = controls.contains(where: \.dark) ? .dark : .light
348
+ }
349
+
350
+ func detach() {
351
+ guard controller.parent != nil else { return }
352
+ controller.willMove(toParent: nil)
353
+ container.removeFromSuperview()
354
+ controller.view.removeFromSuperview()
355
+ controller.removeFromParent()
356
+ owner = nil
357
+ }
358
+ }