@rdlabo/ionic-theme-ios27 1.2.0-2 → 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.
@@ -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])
@@ -607,4 +607,41 @@ final class ShellSnapshotTests: XCTestCase {
607
607
  XCTAssertEqual(phases, ["input"])
608
608
  }
609
609
 
610
+ func testVerticalSearchBridgesInputFocusAndDismissalWithoutStaleWebEchoes() throws {
611
+ guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native glass search") }
612
+ func node(active: Bool, value: String = "", version: Int = 0) throws -> ShellControl {
613
+ let search: JSObject = ["id": "search", "field": item(),
614
+ "trigger": item(["id": "trigger"]), "closeId": "close", "active": active,
615
+ "available": true, "focused": false, "value": value, "placeholder": "Search",
616
+ "disabled": false, "editSequence": 0, "valueVersion": version]
617
+ return try XCTUnwrap(decode([control(["kind": "ion-tab-bar", "placement": "vertical-bars",
618
+ "search": search, "items": [item(["id": "first", "selected": true]), item(["id": "second"])]])]).controls.first)
619
+ }
620
+ var actions: [String] = []
621
+ var values: [String] = []
622
+ let rail = UIView()
623
+ let model = ShellVerticalSearchModel(try XCTUnwrap(node(active: false).search), in: rail,
624
+ activate: { actions.append($0) },
625
+ changed: { _, _, value, _, _ in values.append(value); return values.count })
626
+ model.present(true)
627
+ XCTAssertEqual(actions, ["trigger"])
628
+ model.apply(try XCTUnwrap(node(active: true).search))
629
+ XCTAssertTrue(model.presented)
630
+ model.input("native query")
631
+ XCTAssertEqual(values, ["native query"])
632
+ model.apply(try XCTUnwrap(node(active: true).search))
633
+ XCTAssertEqual(model.text, "native query")
634
+ // The first presentation acknowledgement must not undo native focus.
635
+ model.focus(true)
636
+ model.apply(try XCTUnwrap(node(active: true).search))
637
+ XCTAssertTrue(model.focused)
638
+ model.apply(try XCTUnwrap(node(active: true, value: "application", version: 1).search))
639
+ XCTAssertEqual(model.text, "application")
640
+ model.present(false)
641
+ XCTAssertEqual(actions, ["trigger", "close"])
642
+ model.apply(try XCTUnwrap(node(active: false).search))
643
+ XCTAssertFalse(model.presented)
644
+ XCTAssertFalse(model.focused)
645
+ }
646
+
610
647
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rdlabo/ionic-theme-ios27",
3
3
  "private": false,
4
- "version": "1.2.0-2",
4
+ "version": "1.2.0-3",
5
5
  "description": "iOS27 Theme for Ionic Framework",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
@@ -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'));
@@ -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 = {
@@ -57,6 +57,14 @@ ion-app.ios-theme-vertical-bars .ios-transition-shade {
57
57
  clip-path: inset(0 var(--ios-theme-vertical-bars-safe-area-right-resolved) 0 var(--ios-theme-vertical-bars-safe-area-left-resolved));
58
58
  }
59
59
 
60
+ // A vertical system rail can leave no top inset for the page header.
61
+ ion-app.ios-theme-vertical-bars
62
+ ion-header.ios:not(.header-collapse-condense, .ios-theme-disabled, .ios26-disabled):not(
63
+ :where(ion-menu *, ion-modal:not(.ios-theme-vertical-bars-modal) *, ion-popover *)
64
+ ) {
65
+ --ion-safe-area-top: max(24px, env(safe-area-inset-top, 0px));
66
+ }
67
+
60
68
  // Keep page backgrounds and the router full width; move only their foregrounds.
61
69
  ion-app.ios-theme-vertical-bars
62
70
  ion-content:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not(
@@ -64,6 +64,8 @@ export const attachTabBarSearchable = (
64
64
  // Initialize
65
65
  ionFooter.style.pointerEvents = 'none';
66
66
  ionFooter.style.opacity = '0';
67
+ // Search dismissal belongs beside the Web search field, never in the vertical rail.
68
+ ionFooter.querySelector('ion-buttons[slot=start]')?.classList.add('ios-theme-horizontal-only');
67
69
  const nativeSearch = registerNativeSearch(ionTabBar, ionFabButton, ionFooter);
68
70
 
69
71
  // Saved Params
@@ -72,6 +74,8 @@ export const attachTabBarSearchable = (
72
74
  // Leave is valid both while native Enter awaits its ack and after it completes.
73
75
  let nativeEntry = false;
74
76
  let nativeRequest = 0;
77
+ let verticalSearch = false;
78
+ let tabVisibilityObserver: MutationObserver | undefined;
75
79
 
76
80
  return async (event: Event, type: TabBarSearchableType) => {
77
81
  const selector = type === TabBarSearchableType.Enter ? 'ion-fab-button' : 'ion-buttons[slot=start] ion-button';
@@ -93,19 +97,38 @@ export const attachTabBarSearchable = (
93
97
  return;
94
98
  }
95
99
  if (type === TabBarSearchableType.Enter) {
96
- resumeNative ??= await suspendNativeUIShell([ionTabBar, ionFooter, ionFabButton.closest('ion-fab') ?? ionFabButton]);
100
+ verticalSearch = !!ionTabBar.closest('ion-app.ios-theme-vertical-bars');
101
+ if (verticalSearch) {
102
+ // Ionic hides horizontal tabs when the keyboard opens; keep this rail available.
103
+ const keepTabsVisible = () => {
104
+ if (ionTabBar.classList.contains('tab-bar-hidden') && ionTabBar.closest('ion-app.ios-theme-vertical-bars')) {
105
+ ionTabBar.classList.remove('tab-bar-hidden');
106
+ }
107
+ };
108
+ tabVisibilityObserver?.disconnect();
109
+ tabVisibilityObserver = new MutationObserver(keepTabsVisible);
110
+ tabVisibilityObserver.observe(ionTabBar, { attributes: true, attributeFilter: ['class'] });
111
+ keepTabsVisible();
112
+ }
113
+ resumeNative ??= await suspendNativeUIShell([
114
+ ...(verticalSearch ? [] : [ionTabBar]),
115
+ ionFooter,
116
+ ionFabButton.closest('ion-fab') ?? ionFabButton,
117
+ ]);
97
118
  try {
98
- searchableEventCache = await enterEvent(event, ionTabBar, ionFabButton, ionFooter);
119
+ searchableEventCache = await enterEvent(event, ionTabBar, ionFabButton, ionFooter, verticalSearch);
99
120
  } catch (error) {
121
+ tabVisibilityObserver?.disconnect();
100
122
  resumeNative();
101
123
  resumeNative = undefined;
102
124
  throw error;
103
125
  }
104
126
  } else if (searchableEventCache !== undefined) {
105
127
  try {
106
- await leaveEvent(event, searchableEventCache, ionTabBar, ionFabButton, ionFooter);
128
+ await leaveEvent(event, searchableEventCache, ionTabBar, ionFabButton, ionFooter, verticalSearch);
107
129
  searchableEventCache = undefined;
108
130
  } finally {
131
+ tabVisibilityObserver?.disconnect();
109
132
  resumeNative?.();
110
133
  resumeNative = undefined;
111
134
  }
@@ -120,6 +143,7 @@ const enterEvent = async (
120
143
  ionTabBar: HTMLElement,
121
144
  ionFabButton: HTMLElement,
122
145
  ionFooter: HTMLElement,
146
+ verticalSearch: boolean,
123
147
  ): Promise<SearchableEventCache> => {
124
148
  if (!(event.target as HTMLElement)?.closest('ion-fab-button')) {
125
149
  throw throwErrorByFailedClickElement('ion-fab-button');
@@ -131,10 +155,12 @@ const enterEvent = async (
131
155
  ? getComputedStyle(references.selectedTabButton).getPropertyValue('--color-selected').trim()
132
156
  : '';
133
157
 
134
- const effectAnimation = createEffectAnimation(references, sizes);
158
+ if (verticalSearch) references.closeButtonIcon.setAttribute('name', 'close');
159
+ const tabAnimations = verticalSearch
160
+ ? []
161
+ : [createTabBarAnimation(ionTabBar, references, sizes), createEffectAnimation(references, sizes)];
135
162
  const searchContainerAnimation = createSearchContainerAnimation(references, sizes);
136
163
  const closeButtonsAnimation = createCloseButtonsAnimation(references);
137
- const tabBarAnimation = createTabBarAnimation(ionTabBar, references, sizes);
138
164
  const fabButtonAnimation = createFabButtonAnimation(ionFabButton);
139
165
 
140
166
  await createAnimation()
@@ -144,7 +170,7 @@ const enterEvent = async (
144
170
  .addElement(ionFooter)
145
171
  .afterAddWrite(() => (ionFooter.style.pointerEvents = 'auto'))
146
172
  .fromTo('opacity', '0.8', '1')
147
- .addAnimation([tabBarAnimation, fabButtonAnimation, searchContainerAnimation, effectAnimation, closeButtonsAnimation])
173
+ .addAnimation([...tabAnimations, fabButtonAnimation, searchContainerAnimation, closeButtonsAnimation])
148
174
  .play();
149
175
 
150
176
  return {
@@ -159,6 +185,7 @@ const leaveEvent = async (
159
185
  ionTabBar: HTMLElement,
160
186
  ionFabButton: HTMLElement,
161
187
  ionFooter: HTMLElement,
188
+ verticalSearch: boolean,
162
189
  ): Promise<void> => {
163
190
  if (!(event.target as HTMLElement)?.closest('ion-buttons[slot=start] ion-button')) {
164
191
  throw throwErrorByFailedClickElement('ion-buttons[slot=start] ion-button');
@@ -166,10 +193,14 @@ const leaveEvent = async (
166
193
 
167
194
  const references = getElementReferences(ionTabBar, ionFooter);
168
195
 
169
- const effectAnimation = createReverseEffectAnimation(references, searchableEventCache.elementSizes, searchableEventCache.colorSelected);
196
+ const tabAnimations = verticalSearch
197
+ ? []
198
+ : [
199
+ createReverseTabBarAnimation(ionTabBar, references, searchableEventCache.elementSizes),
200
+ createReverseEffectAnimation(references, searchableEventCache.elementSizes, searchableEventCache.colorSelected),
201
+ ];
170
202
  const searchContainerAnimation = createReverseSearchContainerAnimation(references, searchableEventCache.elementSizes);
171
203
  const closeButtonsAnimation = createReverseCloseButtonsAnimation(references);
172
- const tabBarAnimation = createReverseTabBarAnimation(ionTabBar, references, searchableEventCache.elementSizes);
173
204
  const fabButtonAnimation = createReverseFabButtonAnimation(ionFabButton, searchableEventCache.elementSizes);
174
205
 
175
206
  await createAnimation()
@@ -179,6 +210,6 @@ const leaveEvent = async (
179
210
  .addElement(ionFooter)
180
211
  .afterAddWrite(() => (ionFooter.style.pointerEvents = 'none'))
181
212
  .fromTo('opacity', '1', '0')
182
- .addAnimation([tabBarAnimation, fabButtonAnimation, searchContainerAnimation, effectAnimation, closeButtonsAnimation])
213
+ .addAnimation([...tabAnimations, fabButtonAnimation, searchContainerAnimation, closeButtonsAnimation])
183
214
  .play();
184
215
  };