@rdlabo/ionic-theme-ios27 1.2.0-2 → 1.2.0-4

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 (180) hide show
  1. package/README.md +15 -2
  2. package/dist/_virtual/_rolldown/runtime.js +13 -0
  3. package/dist/css/ionic-theme-ios27.css +1 -1
  4. package/dist/css/vertical-bars.css +1 -1
  5. package/dist/focus-controller/index.d.ts +4 -2
  6. package/dist/focus-controller/index.d.ts.map +1 -1
  7. package/dist/focus-controller/index.js +100 -57
  8. package/dist/focus-controller/index.js.map +1 -0
  9. package/dist/index.d.ts +10 -8
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +54 -60
  12. package/dist/index.js.map +1 -0
  13. package/dist/native/components/index.d.ts +13 -10
  14. package/dist/native/components/index.d.ts.map +1 -1
  15. package/dist/native/components/index.js +39 -41
  16. package/dist/native/components/index.js.map +1 -0
  17. package/dist/native/components/ion-back-button.d.ts +6 -1
  18. package/dist/native/components/ion-back-button.d.ts.map +1 -1
  19. package/dist/native/components/ion-back-button.js +19 -13
  20. package/dist/native/components/ion-back-button.js.map +1 -0
  21. package/dist/native/components/ion-button.d.ts +8 -2
  22. package/dist/native/components/ion-button.d.ts.map +1 -1
  23. package/dist/native/components/ion-button.js +22 -17
  24. package/dist/native/components/ion-button.js.map +1 -0
  25. package/dist/native/components/ion-buttons.d.ts +8 -2
  26. package/dist/native/components/ion-buttons.d.ts.map +1 -1
  27. package/dist/native/components/ion-buttons.js +29 -33
  28. package/dist/native/components/ion-buttons.js.map +1 -0
  29. package/dist/native/components/ion-fab.d.ts +6 -1
  30. package/dist/native/components/ion-fab.d.ts.map +1 -1
  31. package/dist/native/components/ion-fab.js +131 -151
  32. package/dist/native/components/ion-fab.js.map +1 -0
  33. package/dist/native/components/ion-menu-button.d.ts +8 -2
  34. package/dist/native/components/ion-menu-button.d.ts.map +1 -1
  35. package/dist/native/components/ion-menu-button.js +29 -19
  36. package/dist/native/components/ion-menu-button.js.map +1 -0
  37. package/dist/native/components/ion-segment.d.ts +6 -1
  38. package/dist/native/components/ion-segment.d.ts.map +1 -1
  39. package/dist/native/components/ion-segment.js +29 -23
  40. package/dist/native/components/ion-segment.js.map +1 -0
  41. package/dist/native/components/ion-tab-bar.d.ts +6 -1
  42. package/dist/native/components/ion-tab-bar.d.ts.map +1 -1
  43. package/dist/native/components/ion-tab-bar.js +49 -40
  44. package/dist/native/components/ion-tab-bar.js.map +1 -0
  45. package/dist/native/components/searchable-tabs.d.ts +17 -15
  46. package/dist/native/components/searchable-tabs.d.ts.map +1 -1
  47. package/dist/native/components/searchable-tabs.js +382 -435
  48. package/dist/native/components/searchable-tabs.js.map +1 -0
  49. package/dist/native/definitions.d.ts +118 -111
  50. package/dist/native/definitions.d.ts.map +1 -1
  51. package/dist/native/definitions.js +0 -1
  52. package/dist/native/foldable/fold-state.d.ts +5 -0
  53. package/dist/native/foldable/fold-state.d.ts.map +1 -0
  54. package/dist/native/foldable/fold-state.js +14 -0
  55. package/dist/native/foldable/fold-state.js.map +1 -0
  56. package/dist/native/foldable/types.d.ts +12 -0
  57. package/dist/native/foldable/types.d.ts.map +1 -0
  58. package/dist/native/foldable/types.js +0 -0
  59. package/dist/native/index.d.ts +6 -3
  60. package/dist/native/index.d.ts.map +1 -1
  61. package/dist/native/index.js +228 -204
  62. package/dist/native/index.js.map +1 -0
  63. package/dist/native/prehide.d.ts +4 -2
  64. package/dist/native/prehide.d.ts.map +1 -1
  65. package/dist/native/prehide.js +220 -242
  66. package/dist/native/prehide.js.map +1 -0
  67. package/dist/native/runtime.d.ts +3 -1
  68. package/dist/native/runtime.d.ts.map +1 -1
  69. package/dist/native/runtime.js +626 -724
  70. package/dist/native/runtime.js.map +1 -0
  71. package/dist/native/shared/candidate.d.ts +16 -11
  72. package/dist/native/shared/candidate.d.ts.map +1 -1
  73. package/dist/native/shared/candidate.js +96 -95
  74. package/dist/native/shared/candidate.js.map +1 -0
  75. package/dist/native/shared/crossfade.d.ts +6 -4
  76. package/dist/native/shared/crossfade.d.ts.map +1 -1
  77. package/dist/native/shared/crossfade.js +49 -45
  78. package/dist/native/shared/crossfade.js.map +1 -0
  79. package/dist/native/shared/dom.d.ts +7 -5
  80. package/dist/native/shared/dom.d.ts.map +1 -1
  81. package/dist/native/shared/dom.js +163 -211
  82. package/dist/native/shared/dom.js.map +1 -0
  83. package/dist/native/shared/events.d.ts +8 -6
  84. package/dist/native/shared/events.d.ts.map +1 -1
  85. package/dist/native/shared/events.js +12 -7
  86. package/dist/native/shared/events.js.map +1 -0
  87. package/dist/native/shared/icons.d.ts +4 -2
  88. package/dist/native/shared/icons.d.ts.map +1 -1
  89. package/dist/native/shared/icons.js +59 -53
  90. package/dist/native/shared/icons.js.map +1 -0
  91. package/dist/native/shared/modal.d.ts +3 -1
  92. package/dist/native/shared/modal.d.ts.map +1 -1
  93. package/dist/native/shared/modal.js +156 -139
  94. package/dist/native/shared/modal.js.map +1 -0
  95. package/dist/native/vertical-bars-web.d.ts +3 -1
  96. package/dist/native/vertical-bars-web.d.ts.map +1 -1
  97. package/dist/native/vertical-bars-web.js +332 -346
  98. package/dist/native/vertical-bars-web.js.map +1 -0
  99. package/dist/native-integration/index.d.ts +10 -8
  100. package/dist/native-integration/index.d.ts.map +1 -1
  101. package/dist/native-integration/index.js +102 -100
  102. package/dist/native-integration/index.js.map +1 -0
  103. package/dist/native-integration/transition.d.ts +3 -1
  104. package/dist/native-integration/transition.d.ts.map +1 -1
  105. package/dist/native-integration/transition.js +15 -6
  106. package/dist/native-integration/transition.js.map +1 -0
  107. package/dist/searchbar/index.d.ts +4 -2
  108. package/dist/searchbar/index.d.ts.map +1 -1
  109. package/dist/searchbar/index.js +64 -56
  110. package/dist/searchbar/index.js.map +1 -0
  111. package/dist/segment/index.d.ts +3 -1
  112. package/dist/segment/index.d.ts.map +1 -1
  113. package/dist/segment/index.js +465 -324
  114. package/dist/segment/index.js.map +1 -0
  115. package/dist/sheets-of-glass/animations.d.ts +13 -12
  116. package/dist/sheets-of-glass/animations.d.ts.map +1 -1
  117. package/dist/sheets-of-glass/animations.js +496 -306
  118. package/dist/sheets-of-glass/animations.js.map +1 -0
  119. package/dist/sheets-of-glass/index.d.ts +3 -1
  120. package/dist/sheets-of-glass/index.d.ts.map +1 -1
  121. package/dist/sheets-of-glass/index.js +294 -357
  122. package/dist/sheets-of-glass/index.js.map +1 -0
  123. package/dist/sheets-of-glass/interfaces.d.ts +11 -9
  124. package/dist/sheets-of-glass/interfaces.d.ts.map +1 -1
  125. package/dist/sheets-of-glass/interfaces.js +0 -1
  126. package/dist/tab-bar-searchable/animations/enter.d.ts +4 -2
  127. package/dist/tab-bar-searchable/animations/enter.d.ts.map +1 -1
  128. package/dist/tab-bar-searchable/animations/enter.js +50 -69
  129. package/dist/tab-bar-searchable/animations/enter.js.map +1 -0
  130. package/dist/tab-bar-searchable/animations/leave.d.ts +4 -2
  131. package/dist/tab-bar-searchable/animations/leave.d.ts.map +1 -1
  132. package/dist/tab-bar-searchable/animations/leave.js +48 -62
  133. package/dist/tab-bar-searchable/animations/leave.js.map +1 -0
  134. package/dist/tab-bar-searchable/index.d.ts +5 -3
  135. package/dist/tab-bar-searchable/index.d.ts.map +1 -1
  136. package/dist/tab-bar-searchable/index.js +125 -109
  137. package/dist/tab-bar-searchable/index.js.map +1 -0
  138. package/dist/transition/index.d.ts +9 -8
  139. package/dist/transition/index.d.ts.map +1 -1
  140. package/dist/transition/index.js +203 -192
  141. package/dist/transition/index.js.map +1 -0
  142. package/dist/transition/ios.transition.d.ts +7 -5
  143. package/dist/transition/ios.transition.d.ts.map +1 -1
  144. package/dist/transition/ios.transition.js +15 -11
  145. package/dist/transition/ios.transition.js.map +1 -0
  146. package/dist/utils.d.ts +10 -8
  147. package/dist/utils.d.ts.map +1 -1
  148. package/dist/utils.js +58 -66
  149. package/dist/utils.js.map +1 -0
  150. package/dist/vertical-bars.d.ts +5 -4
  151. package/dist/vertical-bars.js +4 -2
  152. package/docs/iphone-duo-with-original-theme.md +18 -29
  153. package/docs/iphone-duo.md +44 -204
  154. package/docs/native-ui-shell.md +15 -11
  155. package/docs/special-markup.md +2 -0
  156. package/docs/vertical-bars.md +283 -0
  157. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +1 -1
  158. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +61 -16
  159. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalSearch.swift +118 -0
  160. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +17 -8
  161. package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +2 -0
  162. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +40 -0
  163. package/package.json +9 -6
  164. package/src/native/components/index.ts +3 -2
  165. package/src/native/components/ion-button.ts +3 -2
  166. package/src/native/components/ion-buttons.ts +4 -3
  167. package/src/native/components/ion-menu-button.ts +3 -2
  168. package/src/native/components/searchable-tabs.ts +8 -11
  169. package/src/native/definitions.ts +22 -1
  170. package/src/native/foldable/fold-state.ts +9 -0
  171. package/src/native/foldable/types.ts +4 -0
  172. package/src/native/index.ts +17 -3
  173. package/src/native/prehide.ts +3 -1
  174. package/src/native/runtime.ts +14 -9
  175. package/src/native/shared/candidate.ts +39 -10
  176. package/src/native/shared/dom.ts +4 -4
  177. package/src/styles/vertical-bars.scss +12 -2
  178. package/src/tab-bar-searchable/index.ts +40 -9
  179. package/src/vertical-bars.ts +9 -1
  180. package/dist/vertical-bars.d.ts.map +0 -1
@@ -0,0 +1,118 @@
1
+ import Combine
2
+ import SwiftUI
3
+ import UIKit
4
+
5
+ @available(iOS 26.0, *)
6
+ final class ShellVerticalSearchModel: ObservableObject {
7
+ @Published private(set) var configuration: ShellSearch
8
+ @Published var text: String
9
+ @Published var presented: Bool
10
+ @Published var focused: Bool
11
+ private weak var railView: UIView?
12
+ private let activate: (String) -> Void
13
+ private let changed: (String, ShellSearchPhase, String, Bool, Int) -> Int
14
+ private let inputDelegate = ShellSearchInputDelegate()
15
+ private var editingSequence = 0
16
+ private var valueVersion: Int
17
+
18
+ init(_ configuration: ShellSearch, in railView: UIView,
19
+ activate: @escaping (String) -> Void,
20
+ changed: @escaping (String, ShellSearchPhase, String, Bool, Int) -> Int) {
21
+ self.configuration = configuration
22
+ self.railView = railView
23
+ self.activate = activate
24
+ self.changed = changed
25
+ text = configuration.value
26
+ presented = configuration.available && configuration.active
27
+ focused = configuration.focused
28
+ valueVersion = configuration.valueVersion
29
+ inputDelegate.clear = { [weak self] in self?.emit(.clear) }
30
+ }
31
+
32
+ var ownsKeyboardChrome: Bool { presented && (focused || textField?.isFirstResponder == true) }
33
+
34
+ private var textField: UISearchTextField? {
35
+ func find(_ view: UIView) -> UISearchTextField? {
36
+ guard !view.isHidden, view.alpha > 0.01 else { return nil }
37
+ if let field = view as? UISearchTextField { return field }
38
+ for child in view.subviews {
39
+ if let field = find(child) { return field }
40
+ }
41
+ return nil
42
+ }
43
+ return railView.flatMap(find)
44
+ }
45
+
46
+ func apply(_ next: ShellSearch) {
47
+ let previous = configuration
48
+ configuration = next
49
+ if next.valueVersion != valueVersion || next.editSequence >= editingSequence {
50
+ valueVersion = next.valueVersion
51
+ text = next.value
52
+ }
53
+ presented = next.available && next.active
54
+ // The first Web acknowledgement still carries the old focus state.
55
+ if !next.active { focused = false }
56
+ else if previous.focused != next.focused { focused = next.focused }
57
+ }
58
+
59
+ func input(_ value: String) {
60
+ text = value
61
+ emit(.input)
62
+ }
63
+
64
+ func present(_ value: Bool) {
65
+ presented = value
66
+ if value != configuration.active {
67
+ activate(value ? configuration.trigger.id : configuration.closeId)
68
+ }
69
+ }
70
+
71
+ func focus(_ value: Bool) {
72
+ focused = value
73
+ emit(value ? .focus : .blur)
74
+ }
75
+
76
+ func commit() { emit(.commit) }
77
+
78
+ func configureField() {
79
+ guard let field = textField else { return }
80
+ field.accessibilityIdentifier = configuration.id
81
+ field.accessibilityLabel = configuration.field.accessibilityLabel
82
+ field.isEnabled = !configuration.disabled
83
+ field.autocapitalizationType = .none
84
+ field.autocorrectionType = .no
85
+ field.spellCheckingType = .no
86
+ field.clearButtonMode = .always
87
+ if field.delegate !== inputDelegate {
88
+ inputDelegate.original = field.delegate
89
+ field.delegate = inputDelegate
90
+ }
91
+ }
92
+
93
+ private func emit(_ phase: ShellSearchPhase) {
94
+ editingSequence = changed(configuration.id, phase, text,
95
+ textField?.markedTextRange != nil, valueVersion)
96
+ }
97
+ }
98
+
99
+ @available(iOS 26.0, *)
100
+ struct ShellVerticalSearchModifier: ViewModifier {
101
+ @ObservedObject var model: ShellVerticalSearchModel
102
+ @FocusState private var focused: Bool
103
+
104
+ @ViewBuilder func body(content: Content) -> some View {
105
+ if model.configuration.available {
106
+ content
107
+ .searchable(text: Binding(get: { model.text }, set: model.input),
108
+ isPresented: Binding(get: { model.presented }, set: model.present),
109
+ placement: .toolbar, prompt: Text(model.configuration.placeholder))
110
+ .searchToolbarBehavior(.minimize)
111
+ .searchPresentationToolbarBehavior(.avoidHidingContent)
112
+ .searchFocused($focused)
113
+ .onSubmit(of: .search, model.commit)
114
+ .onChange(of: focused) { _, value in model.focus(value) }
115
+ .onChange(of: model.focused) { _, value in focused = value }
116
+ } else { content }
117
+ }
118
+ }
@@ -46,7 +46,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
46
46
  self.host?.isHidden = true
47
47
  self.verticalBars?.view.isHidden = true
48
48
  }
49
- var searchOwnsKeyboard = false
49
+ var searchOwnsKeyboard = self.verticalBars?.ownsKeyboardChrome == true
50
50
  self.searchControllers.values.forEach { controller in
51
51
  if controller.ownsKeyboardChrome { searchOwnsKeyboard = true }
52
52
  if !keyboard { controller.surface.isHidden = true }
@@ -195,6 +195,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
195
195
  let existing = Set(self.controls.keys)
196
196
  let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars }
197
197
  let snapshots = snapshot.controls.filter { $0.placement != .verticalBars }
198
+ let verticalSearchActive = verticalBars.contains { $0.search.map { $0.available && $0.active } ?? false }
198
199
  let width = snapshot.viewportWidth
199
200
  self.revision = next
200
201
  if snapshots.isEmpty && verticalBars.isEmpty {
@@ -210,12 +211,16 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
210
211
  var fabs: [(ShellFab, ShellControl)] = []
211
212
  var searches: [(ShellSearchControlling, ShellControl, CGRect, CGRect, UIView?, Bool)] = []
212
213
  var rejectedSearches: [String] = []
213
- if verticalBars.isEmpty || self.keyboardVisible {
214
+ if verticalBars.isEmpty || (self.keyboardVisible && !verticalSearchActive) {
214
215
  self.verticalBars?.detach()
215
216
  self.verticalBars = nil
216
217
  if self.keyboardVisible { rejectedControls.append(contentsOf: verticalBars.map(\.id)) }
217
218
  } else if let owner = self.bridge?.viewController {
218
- let rail = self.verticalBars ?? ShellVerticalBarsController(activate: { [weak self] id in self?.activate(id) })
219
+ let rail = self.verticalBars ?? ShellVerticalBarsController(
220
+ activate: { [weak self] id in self?.activate(id) },
221
+ changed: { [weak self] id, phase, value, composing, valueVersion in
222
+ self?.searchChanged(id, phase: phase, value: value, composing: composing, valueVersion: valueVersion) ?? 0
223
+ })
219
224
  self.verticalBars = rail
220
225
  rail.attach(to: owner, in: owner.view)
221
226
  if let frame = snapshot.verticalBarFrame {
@@ -274,11 +279,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
274
279
  controller = ShellSearchController()
275
280
  controller.activate = { [weak self] id in self?.activate(id) }
276
281
  controller.changed = { [weak self] id, phase, value, composing, valueVersion in
277
- guard let self else { return 0 }
278
- self.sequence += 1
279
- self.notifyListeners("search", data: ["id": id, "phase": phase.rawValue, "value": value,
280
- "composing": composing, "valueVersion": valueVersion, "sequence": self.sequence, "revision": self.revision])
281
- return self.sequence
282
+ self?.searchChanged(id, phase: phase, value: value, composing: composing, valueVersion: valueVersion) ?? 0
282
283
  }
283
284
  controller.attach(to: owner, in: owner.view)
284
285
  self.searchControllers[id] = controller
@@ -379,6 +380,14 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele
379
380
  activate(itemId)
380
381
  }
381
382
 
383
+ private func searchChanged(_ id: String, phase: ShellSearchPhase, value: String,
384
+ composing: Bool, valueVersion: Int) -> Int {
385
+ sequence += 1
386
+ notifyListeners("search", data: ["id": id, "phase": phase.rawValue, "value": value,
387
+ "composing": composing, "valueVersion": valueVersion, "sequence": sequence, "revision": revision])
388
+ return sequence
389
+ }
390
+
382
391
  private func activate(_ id: String) {
383
392
  sequence += 1
384
393
  notifyListeners("activate", data: ["id": id, "revision": revision, "sequence": sequence])
@@ -99,6 +99,7 @@ struct ShellBadge: Decodable, Equatable {
99
99
 
100
100
  struct ShellItemContent: Decodable, Equatable {
101
101
  enum IconPosition: String, Decodable { case leading, trailing, top }
102
+ enum ButtonFill: String, Decodable { case clear, solid, outline }
102
103
  let id: String
103
104
  var label: String
104
105
  let accessibilityLabel: String
@@ -107,6 +108,7 @@ struct ShellItemContent: Decodable, Equatable {
107
108
  let fontSize: Double
108
109
  let fontWeight: Double
109
110
  let color: String
111
+ let buttonFill: ButtonFill?
110
112
  let backgroundColor: String?
111
113
  let borderColor: String?
112
114
  let borderWidth: CGFloat?
@@ -53,16 +53,19 @@ final class ShellSnapshotTests: XCTestCase {
53
53
  guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive controls") }
54
54
  let controls = try decode([control(["kind": "ion-buttons", "items": [
55
55
  item(["backgroundColor": "rgb(0, 122, 255)"]),
56
+ item(["id": "clear", "buttonFill": "clear"]),
56
57
  item(["id": "outline", "borderColor": "rgb(255, 0, 0)", "borderWidth": 2.0])
57
58
  ]])]).controls
58
59
  XCTAssertEqual(controls.first?.items.first?.content.backgroundColor, "rgb(0, 122, 255)")
59
60
  let model = ShellVerticalBarsModel()
60
61
  model.apply(controls, rendering: ShellRendering())
61
62
  XCTAssertNotNil(model.groups.first?.items.first?.background)
63
+ XCTAssertEqual(model.groups.first?.items.map(\.clear), [false, true, false])
62
64
  XCTAssertNotNil(model.groups.first?.items.last?.borderColor)
63
65
  XCTAssertEqual(model.groups.first?.items.last?.borderWidth, 2)
64
66
  model.apply(try decode([control(["kind": "ion-button"])]).controls, rendering: ShellRendering())
65
67
  XCTAssertNil(model.groups.first?.items.first?.background)
68
+ XCTAssertEqual(model.groups.first?.items.first?.clear, false)
66
69
  XCTAssertNil(model.groups.first?.items.first?.borderColor)
67
70
  XCTAssertEqual(model.groups.first?.items.first?.borderWidth, 0)
68
71
  }
@@ -607,4 +610,41 @@ final class ShellSnapshotTests: XCTestCase {
607
610
  XCTAssertEqual(phases, ["input"])
608
611
  }
609
612
 
613
+ func testVerticalSearchBridgesInputFocusAndDismissalWithoutStaleWebEchoes() throws {
614
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native glass search") }
615
+ func node(active: Bool, value: String = "", version: Int = 0) throws -> ShellControl {
616
+ let search: JSObject = ["id": "search", "field": item(),
617
+ "trigger": item(["id": "trigger"]), "closeId": "close", "active": active,
618
+ "available": true, "focused": false, "value": value, "placeholder": "Search",
619
+ "disabled": false, "editSequence": 0, "valueVersion": version]
620
+ return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "placement": "vertical-bars",
621
+ "search": search, "items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
622
+ }
623
+ var actions: [String] = []
624
+ var values: [String] = []
625
+ let rail = UIView()
626
+ let model = ShellVerticalSearchModel(try XCTUnwrap(node(active: false).search), in: rail,
627
+ activate: { actions.append($0) },
628
+ changed: { _, _, value, _, _ in values.append(value); return values.count })
629
+ model.present(true)
630
+ XCTAssertEqual(actions, ["trigger"])
631
+ model.apply(try XCTUnwrap(node(active: true).search))
632
+ XCTAssertTrue(model.presented)
633
+ model.input("native query")
634
+ XCTAssertEqual(values, ["native query"])
635
+ model.apply(try XCTUnwrap(node(active: true).search))
636
+ XCTAssertEqual(model.text, "native query")
637
+ // The first presentation acknowledgement must not undo native focus.
638
+ model.focus(true)
639
+ model.apply(try XCTUnwrap(node(active: true).search))
640
+ XCTAssertTrue(model.focused)
641
+ model.apply(try XCTUnwrap(node(active: true, value: "application", version: 1).search))
642
+ XCTAssertEqual(model.text, "application")
643
+ model.present(false)
644
+ XCTAssertEqual(actions, ["trigger", "close"])
645
+ model.apply(try XCTUnwrap(node(active: false).search))
646
+ XCTAssertFalse(model.presented)
647
+ XCTAssertFalse(model.focused)
648
+ }
649
+
610
650
  }
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-2",
4
+ "version": "1.2.0-4",
5
5
  "description": "iOS27 Theme for Ionic Framework",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
@@ -34,13 +34,14 @@
34
34
  "build": "npm run docgen && npm run build:css && npm run build:ts",
35
35
  "prebuild:css": "rdlabo-copy-structured-list src/styles/utils/structured-list.scss",
36
36
  "build:css": "rm -rf dist/css && sass src/styles:dist/css --style=compressed --no-source-map",
37
- "build:ts": "tsc",
38
- "docgen": "docgen --project tsconfig.docgen.json --api NativeUIShellHandle --output-readme docs/native-ui-shell.md && docgen --project tsconfig.docgen.json --api VerticalControlAreaHandle --output-readme docs/iphone-duo.md",
37
+ "build:ts": "tsc --noEmit && rdlabo-build-theme",
38
+ "docgen": "docgen --project tsconfig.docgen.json --api NativeUIShellHandle --output-readme docs/native-ui-shell.md && docgen --project tsconfig.docgen.json --api VerticalControlAreaHandle --output-readme docs/vertical-bars.md",
39
39
  "build:demo": "npm run build && cd demo && npm install && npm run build -- --configuration=production",
40
40
  "lint": "prettier --check \"./**/*.{scss,ts}\" && prettier --parser angular --check \"./**/*.html\"",
41
41
  "fmt": "prettier --write \"./**/*.{scss,ts}\" && prettier --parser angular --write \"./**/*.html\"",
42
42
  "release": "np --no-tests --no-publish",
43
- "prepublishOnly": "npm run build"
43
+ "prepublishOnly": "npm run build",
44
+ "test:esm": "rdlabo-check-esm"
44
45
  },
45
46
  "repository": {
46
47
  "type": "git",
@@ -60,7 +61,7 @@
60
61
  },
61
62
  "homepage": "https://docs.rdlabo.dev/projects/ionic-theme-ios27",
62
63
  "dependencies": {
63
- "@rdlabo/ionic-theme-utils": "0.1.1"
64
+ "@rdlabo/ionic-theme-utils": "0.1.2"
64
65
  },
65
66
  "devDependencies": {
66
67
  "@capacitor/core": "^8.5.2",
@@ -71,6 +72,7 @@
71
72
  "np": "^10.2.0",
72
73
  "prettier": "^3.6.2",
73
74
  "sass": "^1.93.2",
75
+ "tsdown": "^0.23.0",
74
76
  "typescript": "^5.9.3",
75
77
  "wrangler": "^4.125.0"
76
78
  },
@@ -87,5 +89,6 @@
87
89
  "ios": {
88
90
  "src": "ios"
89
91
  }
90
- }
92
+ },
93
+ "type": "module"
91
94
  }
@@ -1,3 +1,4 @@
1
+ import type { VerticalControlAreaOptions } from '../definitions';
1
2
  import * as button from './ion-button';
2
3
  import * as buttons from './ion-buttons';
3
4
  import * as backButton from './ion-back-button';
@@ -29,7 +30,7 @@ export const motionSelector = [
29
30
 
30
31
  export const isVerticalBarsCandidate = isVerticalBarsSource;
31
32
 
32
- export const readCandidate = (element: HTMLElement, id: Identify): Candidate | undefined => {
33
+ export const readCandidate = (element: HTMLElement, id: Identify, options: VerticalControlAreaOptions = {}): Candidate | undefined => {
33
34
  const verticalBars = isVerticalBarsCandidate(element);
34
35
  if (
35
36
  (!element.classList.contains('ios') && !verticalBars) ||
@@ -47,5 +48,5 @@ export const readCandidate = (element: HTMLElement, id: Identify): Candidate | u
47
48
  )
48
49
  return;
49
50
  if (element.contains(element.ownerDocument.activeElement)) return;
50
- return components.find((component) => component.tag === element.localName)?.read(element, id);
51
+ return components.find((component) => component.tag === element.localName)?.read(element, id, options);
51
52
  };
@@ -1,3 +1,4 @@
1
+ import type { VerticalControlAreaOptions } from '../definitions';
1
2
  import { modalUsesVerticalBars } from '../shared/modal';
2
3
  import { createCandidate, appendItem } from '../shared/candidate';
3
4
  import type { Candidate, Identify } from '../shared/candidate';
@@ -5,7 +6,7 @@ import { inFixedToolbar, isVerticalBarsToolbarAction, isVerticalBarsToolbarGroup
5
6
 
6
7
  export const tag = 'ion-button';
7
8
 
8
- export const read = (element: HTMLElement, id: Identify): Candidate | undefined => {
9
+ export const read = (element: HTMLElement, id: Identify, options: VerticalControlAreaOptions = {}): Candidate | undefined => {
9
10
  const button = element as HTMLIonButtonElement;
10
11
  const verticalBars =
11
12
  !element.closest('ion-menu, ion-popover') && modalUsesVerticalBars(element) && !!element.closest('ion-app.ios-theme-vertical-bars');
@@ -18,6 +19,6 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined
18
19
  (!verticalBars && button.classList.contains('ion-color'))
19
20
  )
20
21
  return;
21
- const candidate = createCandidate(element, tag, id);
22
+ const candidate = createCandidate(element, tag, id, options);
22
23
  return appendItem(candidate, element, id) ? candidate : undefined;
23
24
  };
@@ -1,3 +1,4 @@
1
+ import type { VerticalControlAreaOptions } from '../definitions';
1
2
  import { modalUsesVerticalBars } from '../shared/modal';
2
3
  import { createCandidate, appendItem } from '../shared/candidate';
3
4
  import type { Candidate, Identify } from '../shared/candidate';
@@ -7,10 +8,10 @@ import * as menuButton from './ion-menu-button';
7
8
  export const tag = 'ion-buttons';
8
9
  export const tracksMotion = true;
9
10
 
10
- export const read = (element: HTMLElement, id: Identify): Candidate | undefined => {
11
+ export const read = (element: HTMLElement, id: Identify, options: VerticalControlAreaOptions = {}): Candidate | undefined => {
11
12
  if (!inFixedToolbar(element)) return;
12
13
  let children = childElements(element);
13
- if (children.length === 1) return menuButton.read(element, id);
14
+ if (children.length === 1) return menuButton.read(element, id, options);
14
15
  const verticalBars =
15
16
  !element.closest('ion-menu, ion-popover') && modalUsesVerticalBars(element) && !!element.closest('ion-app.ios-theme-vertical-bars');
16
17
  if (verticalBars && !isVerticalBarsToolbarGroup(element)) return;
@@ -25,7 +26,7 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined
25
26
  )
26
27
  )
27
28
  return;
28
- const candidate = createCandidate(element, tag, id);
29
+ const candidate = createCandidate(element, tag, id, options);
29
30
  if (verticalBars && children.length !== childElements(element).length) candidate.sources = children;
30
31
  for (const child of children) {
31
32
  const supported = child.matches(menuButton.tag)
@@ -1,5 +1,6 @@
1
1
  import { createCandidate, appendItem } from '../shared/candidate';
2
2
  import type { Candidate, Identify } from '../shared/candidate';
3
+ import type { VerticalControlAreaOptions } from '../definitions';
3
4
  import { childElements, inFixedToolbar } from '../shared/dom';
4
5
 
5
6
  export const tag = 'ion-menu-button';
@@ -13,11 +14,11 @@ export const append = (candidate: Candidate, button: HTMLIonMenuButtonElement, i
13
14
  return !!content && !!appendItem(candidate, button, id, content);
14
15
  };
15
16
 
16
- export const read = (group: HTMLElement, id: Identify): Candidate | undefined => {
17
+ export const read = (group: HTMLElement, id: Identify, options: VerticalControlAreaOptions = {}): Candidate | undefined => {
17
18
  const children = group.matches('ion-buttons') ? childElements(group) : [];
18
19
  if (!inFixedToolbar(group) || children.length !== 1) return;
19
20
  const button = children[0];
20
21
  if (!button?.matches(`${tag}${group.closest('ion-app.ios-theme-vertical-bars') ? '' : '.ios'}`)) return;
21
- const candidate = createCandidate(group, tag, id);
22
+ const candidate = createCandidate(group, tag, id, options);
22
23
  return append(candidate, button as HTMLIonMenuButtonElement, id) ? candidate : undefined;
23
24
  };
@@ -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'));
@@ -10,7 +10,26 @@ export interface NativeUIShellStatus {
10
10
  reason?: string;
11
11
  }
12
12
 
13
- export interface NativeUIShellOptions {
13
+ export interface VerticalControlAreaOptions {
14
+ /** Appearance of native vertical ion-button and ion-menu-button actions.
15
+ * `system` (default) uses SwiftUI styling and template icons; `source` projects supported Ionic fill and colors.
16
+ * Local data-projection="source|system" or ios-theme-projection-source / ios-theme-projection-system take precedence:
17
+ * the button itself, then its nearest ion-buttons, then this option. On the same element, a valid attribute wins;
18
+ * otherwise system wins if both classes are present. Invalid attribute values are ignored.
19
+ * Local settings update live; removing them restores inheritance. Actions, disabled state and grouping are preserved.
20
+ * Does not affect back buttons, tabs, FABs, horizontal controls, source elements or Web clones.
21
+ */
22
+ buttonProjection?: 'source' | 'system';
23
+ /** Default fill for native vertical ion-button actions resolved to `source`, including local overrides.
24
+ * Use `solid` for Ionic's default design, or `null` (also the omitted default) for the iOS theme's glass design.
25
+ * Applies when fill is omitted or `default`, outside ion-buttons. Inside ion-buttons the default is clear.
26
+ * Explicit clear, solid and outline take precedence; clear and outline cannot be configured as defaults.
27
+ * Source elements and Web clones are unchanged.
28
+ */
29
+ buttonDefaultFill?: 'solid' | null;
30
+ }
31
+
32
+ export interface NativeUIShellOptions extends VerticalControlAreaOptions {
14
33
  /** Enables Native UI Shell globally. Defaults to `true`. */
15
34
  enabled?: boolean;
16
35
  /** Controls eligible for native projection. Omit to enable every control; when present, only `true` controls are enabled. */
@@ -82,6 +101,8 @@ export interface ShellItem extends Frame {
82
101
  fontSize: number;
83
102
  fontWeight: number;
84
103
  color: string;
104
+ /** Resolved ion-button fill for vertical projection; clear omits the native glass background. */
105
+ buttonFill?: 'clear' | 'solid' | 'outline';
85
106
  backgroundColor?: string;
86
107
  borderColor?: string;
87
108
  borderWidth?: number;
@@ -0,0 +1,9 @@
1
+ import type { FoldState } from './types';
2
+
3
+ /** Keep posture on ion-app so descendants can follow it through CSS. */
4
+ export function applyFoldStateClasses(root: HTMLElement, fold: FoldState): void {
5
+ for (const state of ['flat', 'half-opened', 'closed']) {
6
+ root.classList.toggle(`ios-theme-fold-${state}`, fold.state === state);
7
+ }
8
+ root.classList.toggle('ios-theme-fold-expanded', fold.state === 'half-opened' || (fold.state === 'flat' && !!fold.hingeBounds));
9
+ }
@@ -0,0 +1,4 @@
1
+ export interface FoldState {
2
+ state: 'flat' | 'half-opened' | 'closed';
3
+ hingeBounds?: { x: number; y: number; width: number; height: number };
4
+ }
@@ -7,6 +7,7 @@ import type {
7
7
  VerticalBarEdge,
8
8
  VerticalBarPlacement,
9
9
  VerticalControlAreaHandle,
10
+ VerticalControlAreaOptions,
10
11
  WebViewMetrics,
11
12
  } from './definitions';
12
13
  import { createRuntime } from './runtime';
@@ -23,6 +24,7 @@ export type {
23
24
  VerticalBarEdge,
24
25
  VerticalBarPlacement,
25
26
  VerticalControlAreaHandle,
27
+ VerticalControlAreaOptions,
26
28
  WebViewMetrics,
27
29
  } from './definitions';
28
30
 
@@ -160,9 +162,18 @@ export const setVerticalControlAreaPlacement = (placement: VerticalBarEdge | Ver
160
162
  document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh'));
161
163
  };
162
164
 
163
- /** Call once at application startup. Ionic markup remains the source of truth. */
164
- export const enableVerticalControlArea = async (): Promise<VerticalControlAreaHandle> => {
165
- const handle = await enableNativeUIShell({ controls: { tabs: true, toolbar: true }, verticalBarsOnly: true });
165
+ /**
166
+ * Call once after ion-app is mounted. Ionic markup remains the source of truth.
167
+ * Native vertical buttons default to SwiftUI appearance.
168
+ * See VerticalControlAreaOptions for source styling and local overrides.
169
+ */
170
+ export const enableVerticalControlArea = async (options: VerticalControlAreaOptions = {}): Promise<VerticalControlAreaHandle> => {
171
+ const handle = await enableNativeUIShell({
172
+ buttonProjection: options.buttonProjection,
173
+ buttonDefaultFill: options.buttonDefaultFill,
174
+ controls: { tabs: true, toolbar: true },
175
+ verticalBarsOnly: true,
176
+ });
166
177
  return {
167
178
  getStatus: () => handle.getStatus(),
168
179
  suspend: () => handle.suspend(),
@@ -188,6 +199,9 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
188
199
  const controls = options.controls;
189
200
  const configuration = JSON.stringify([
190
201
  options.verticalBarsOnly === true,
202
+ options.buttonProjection ?? 'system',
203
+ // Local source overrides can use this even when the startup projection is system.
204
+ options.buttonDefaultFill ?? null,
191
205
  ...(['tabs', 'toolbar', 'segment', 'fab'] as const).map((component) => !controls || controls[component] === true),
192
206
  ]);
193
207
  if (active && activeConfiguration !== configuration)
@@ -74,6 +74,8 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: ()
74
74
  };
75
75
  const sources = scope.matches('ion-back-button') ? [scope] : Array.from(scope.querySelectorAll<HTMLElement>(sourceSelector));
76
76
  sources.forEach((element) => {
77
+ // Shell opt-out is temporary; decide initial placement once it is lifted.
78
+ if (isShellDisabled(element)) return;
77
79
  if (!modalUsesVerticalBars(element) || element.closest(overlays) || (scope.matches('.ion-page') && routedPage(element) !== scope))
78
80
  return;
79
81
  if (element.matches('ion-buttons')) {
@@ -228,7 +230,7 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: ()
228
230
  childList: true,
229
231
  attributes: true,
230
232
  attributeOldValue: true,
231
- attributeFilter: ['class', 'hidden', 'inert', 'icon', 'color'],
233
+ attributeFilter: ['class', 'data-shell', 'hidden', 'inert', 'icon', 'color'],
232
234
  });
233
235
  return {
234
236
  suspend: () => {
@@ -172,14 +172,14 @@ export const createRuntime = async (
172
172
  measuringPointerPages.add(pointerPage);
173
173
  pointerPage.style.setProperty('pointer-events', 'auto', 'important');
174
174
  try {
175
- candidate = readCandidate(element, id);
175
+ candidate = readCandidate(element, id, options);
176
176
  } finally {
177
177
  if (previous) pointerPage.style.setProperty('pointer-events', previous, priority);
178
178
  else pointerPage.style.removeProperty('pointer-events');
179
179
  if (!hadStyle && !pointerPage.style.length) pointerPage.removeAttribute('style');
180
180
  win.setTimeout(() => measuringPointerPages.delete(pointerPage), 0);
181
181
  }
182
- } else candidate = readCandidate(element, id);
182
+ } else candidate = readCandidate(element, id, options);
183
183
  if (candidate && isVerticalBarsCandidate(element)) {
184
184
  if (!nativeVerticalBars()) return undefined;
185
185
  candidate.control.placement = 'vertical-bars';
@@ -608,24 +608,29 @@ export const createRuntime = async (
608
608
  'focusout',
609
609
  ])
610
610
  on(doc, name);
611
- on(doc, 'visibilitychange', () => {
612
- if (doc.hidden) getNativeSearchBindings(doc).forEach((binding) => search.retire(binding));
611
+ const refreshLayout = () => {
612
+ rejected = new WeakMap();
613
+ forceRefresh = true;
613
614
  schedule();
615
+ };
616
+ on(doc, 'visibilitychange', () => {
617
+ if (doc.hidden) {
618
+ getNativeSearchBindings(doc).forEach((binding) => search.retire(binding));
619
+ schedule();
620
+ } else refreshLayout();
614
621
  });
615
- on(win, 'resize');
622
+ on(win, 'resize', refreshLayout);
616
623
  on(win, 'keyboardWillShow', () => search.keyboard(true));
617
624
  on(win, 'keyboardWillHide', () => search.keyboard(false));
618
625
  on(win, 'scroll');
619
626
  on(win.matchMedia('(prefers-color-scheme: dark)'), 'change');
620
627
  on(win, 'nativeUIShellRefresh', (event) => {
621
- rejected = new WeakMap();
622
628
  if ((event as Event & { retireSearch?: boolean }).retireSearch)
623
629
  getNativeSearchBindings(doc).forEach((binding) => search.retire(binding));
624
- forceRefresh = true;
625
- schedule();
630
+ refreshLayout();
626
631
  });
627
632
  if (win.visualViewport) {
628
- on(win.visualViewport, 'resize');
633
+ on(win.visualViewport, 'resize', refreshLayout);
629
634
  on(win.visualViewport, 'scroll');
630
635
  }
631
636
  const handle: NativeUIShellHandle = {