@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.
- package/dist/css/ionic-theme-ios27.css +1 -1
- package/dist/css/vertical-bars.css +1 -1
- package/dist/native/components/searchable-tabs.d.ts.map +1 -1
- package/dist/native/components/searchable-tabs.js +6 -7
- package/dist/native/runtime.d.ts.map +1 -1
- package/dist/native/runtime.js +13 -7
- package/dist/tab-bar-searchable/index.d.ts.map +1 -1
- package/dist/tab-bar-searchable/index.js +39 -11
- package/docs/native-ui-shell.md +5 -3
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +1 -1
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +27 -2
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalSearch.swift +118 -0
- package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +17 -8
- package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +37 -0
- package/package.json +1 -1
- package/src/native/components/searchable-tabs.ts +8 -11
- package/src/native/runtime.ts +12 -7
- package/src/styles/vertical-bars.scss +8 -0
- package/src/tab-bar-searchable/index.ts +40 -9
|
@@ -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 =
|
|
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(
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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'));
|
package/src/native/runtime.ts
CHANGED
|
@@ -608,24 +608,29 @@ export const createRuntime = async (
|
|
|
608
608
|
'focusout',
|
|
609
609
|
])
|
|
610
610
|
on(doc, name);
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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([
|
|
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
|
|
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([
|
|
213
|
+
.addAnimation([...tabAnimations, fabButtonAnimation, searchContainerAnimation, closeButtonsAnimation])
|
|
183
214
|
.play();
|
|
184
215
|
};
|