@rdlabo/ionic-theme-ios27 1.2.0-beta.pr230.shac8f41fd85378 → 1.2.0-beta.pr233.sha647eef34274b
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/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/tab-bar-searchable/index.ts +40 -9
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"searchable-tabs.d.ts","sourceRoot":"","sources":["../../../src/native/components/searchable-tabs.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAGpE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,KAAK,EAA0B,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAmB/E,eAAO,MAAM,mBAAmB,GAAI,KAAK,QAAQ,EAAE,IAAI,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,EAAE,UAAU,MAAM,IAAI;sBA8InF,OAAO;;
|
|
1
|
+
{"version":3,"file":"searchable-tabs.d.ts","sourceRoot":"","sources":["../../../src/native/components/searchable-tabs.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAGpE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,KAAK,EAA0B,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAmB/E,eAAO,MAAM,mBAAmB,GAAI,KAAK,QAAQ,EAAE,IAAI,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,EAAE,UAAU,MAAM,IAAI;sBA8InF,OAAO;;yBAwBJ,SAAS,EAAE,WAAW,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,GAAG,SAAS,EAAE;yBAvJhE,mBAAmB;;mBA+U9B,mBAAmB,YAAY,MAAM;uBAKjC,mBAAmB,UAAU,OAAO;qBAUtC,WAAW;gBAGhB,MAAM,EAAE;iBASP,gBAAgB;oBAqCb,mBAAmB;;CAoBtC,CAAC"}
|
|
@@ -151,7 +151,11 @@ export const createSearchSupport = (doc, id, schedule) => {
|
|
|
151
151
|
keyboard(visible) {
|
|
152
152
|
keyboardVisible = visible;
|
|
153
153
|
if (!visible) {
|
|
154
|
-
|
|
154
|
+
for (const [binding, state] of states) {
|
|
155
|
+
if (state.keyboardHidden)
|
|
156
|
+
binding.tabBar.classList.remove('tab-bar-hidden');
|
|
157
|
+
state.keyboardHidden = false;
|
|
158
|
+
}
|
|
155
159
|
releaseKeyboardResize();
|
|
156
160
|
}
|
|
157
161
|
schedule();
|
|
@@ -184,10 +188,7 @@ export const createSearchSupport = (doc, id, schedule) => {
|
|
|
184
188
|
};
|
|
185
189
|
for (const binding of bindings) {
|
|
186
190
|
const candidate = candidates.find((c) => c.element === binding.tabBar);
|
|
187
|
-
if (!candidate ||
|
|
188
|
-
candidate.control.search ||
|
|
189
|
-
candidate.control.placement === 'vertical-bars' ||
|
|
190
|
-
binding.tabBar.getAttribute('slot') !== 'bottom')
|
|
191
|
+
if (!candidate || candidate.control.search || binding.tabBar.getAttribute('slot') !== 'bottom')
|
|
191
192
|
continue;
|
|
192
193
|
const fab = binding.trigger.parentElement;
|
|
193
194
|
if (!fab?.matches('ion-fab[slot="fixed"]') ||
|
|
@@ -415,8 +416,6 @@ export const createSearchSupport = (doc, id, schedule) => {
|
|
|
415
416
|
const state = states.get(binding);
|
|
416
417
|
if (state?.keyboardHidden && keyboardVisible)
|
|
417
418
|
binding.tabBar.classList.add('tab-bar-hidden');
|
|
418
|
-
if (state)
|
|
419
|
-
state.keyboardHidden = false;
|
|
420
419
|
if (state?.focused) {
|
|
421
420
|
state.focused = false;
|
|
422
421
|
state.input.dispatchEvent(new FocusEvent('blur'));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/native/runtime.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAGV,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,EAEpB,MAAM,eAAe,CAAC;AA6BvB,eAAO,MAAM,aAAa,GACxB,KAAK,QAAQ,EACb,QAAQ,mBAAmB,EAC3B,UAAS,oBAAyB,EAClC,qBAAoB,MAAM,OAAoB,EAC9C,0BAAwB,KACvB,OAAO,CAAC,mBAAmB,
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/native/runtime.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAGV,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,EAEpB,MAAM,eAAe,CAAC;AA6BvB,eAAO,MAAM,aAAa,GACxB,KAAK,QAAQ,EACb,QAAQ,mBAAmB,EAC3B,UAAS,oBAAyB,EAClC,qBAAoB,MAAM,OAAoB,EAC9C,0BAAwB,KACvB,OAAO,CAAC,mBAAmB,CAytB7B,CAAC"}
|
package/dist/native/runtime.js
CHANGED
|
@@ -569,25 +569,31 @@ export const createRuntime = async (doc, plugin, options = {}, nativeVerticalBar
|
|
|
569
569
|
'focusout',
|
|
570
570
|
])
|
|
571
571
|
on(doc, name);
|
|
572
|
+
const refreshLayout = () => {
|
|
573
|
+
rejected = new WeakMap();
|
|
574
|
+
forceRefresh = true;
|
|
575
|
+
schedule();
|
|
576
|
+
};
|
|
572
577
|
on(doc, 'visibilitychange', () => {
|
|
573
|
-
if (doc.hidden)
|
|
578
|
+
if (doc.hidden) {
|
|
574
579
|
getNativeSearchBindings(doc).forEach((binding) => search.retire(binding));
|
|
575
|
-
|
|
580
|
+
schedule();
|
|
581
|
+
}
|
|
582
|
+
else
|
|
583
|
+
refreshLayout();
|
|
576
584
|
});
|
|
577
|
-
on(win, 'resize');
|
|
585
|
+
on(win, 'resize', refreshLayout);
|
|
578
586
|
on(win, 'keyboardWillShow', () => search.keyboard(true));
|
|
579
587
|
on(win, 'keyboardWillHide', () => search.keyboard(false));
|
|
580
588
|
on(win, 'scroll');
|
|
581
589
|
on(win.matchMedia('(prefers-color-scheme: dark)'), 'change');
|
|
582
590
|
on(win, 'nativeUIShellRefresh', (event) => {
|
|
583
|
-
rejected = new WeakMap();
|
|
584
591
|
if (event.retireSearch)
|
|
585
592
|
getNativeSearchBindings(doc).forEach((binding) => search.retire(binding));
|
|
586
|
-
|
|
587
|
-
schedule();
|
|
593
|
+
refreshLayout();
|
|
588
594
|
});
|
|
589
595
|
if (win.visualViewport) {
|
|
590
|
-
on(win.visualViewport, 'resize');
|
|
596
|
+
on(win.visualViewport, 'resize', refreshLayout);
|
|
591
597
|
on(win.visualViewport, 'scroll');
|
|
592
598
|
}
|
|
593
599
|
const handle = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/tab-bar-searchable/index.ts"],"names":[],"mappings":"AACA,OAAO,EAQL,KAAK,wBAAwB,EAE9B,MAAM,2BAA2B,CAAC;AAiBnC,OAAO,EACL,oBAAoB,EACpB,KAAK,wBAAwB,EAC7B,KAAK,oBAAoB,EACzB,KAAK,YAAY,EACjB,KAAK,iBAAiB,GACvB,MAAM,2BAA2B,CAAC;AAqBnC,eAAO,MAAM,sBAAsB,GACjC,WAAW,WAAW,EACtB,cAAc,WAAW,EACzB,WAAW,WAAW,KACrB,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/tab-bar-searchable/index.ts"],"names":[],"mappings":"AACA,OAAO,EAQL,KAAK,wBAAwB,EAE9B,MAAM,2BAA2B,CAAC;AAiBnC,OAAO,EACL,oBAAoB,EACpB,KAAK,wBAAwB,EAC7B,KAAK,oBAAoB,EACzB,KAAK,YAAY,EACjB,KAAK,iBAAiB,GACvB,MAAM,2BAA2B,CAAC;AAqBnC,eAAO,MAAM,sBAAsB,GACjC,WAAW,WAAW,EACtB,cAAc,WAAW,EACzB,WAAW,WAAW,KACrB,wBA+EF,CAAC"}
|
|
@@ -10,11 +10,14 @@ export const attachTabBarSearchable = (ionTabBar, ionFabButton, ionFooter) => {
|
|
|
10
10
|
}
|
|
11
11
|
ionFooter.style.pointerEvents = 'none';
|
|
12
12
|
ionFooter.style.opacity = '0';
|
|
13
|
+
ionFooter.querySelector('ion-buttons[slot=start]')?.classList.add('ios-theme-horizontal-only');
|
|
13
14
|
const nativeSearch = registerNativeSearch(ionTabBar, ionFabButton, ionFooter);
|
|
14
15
|
let searchableEventCache;
|
|
15
16
|
let resumeNative;
|
|
16
17
|
let nativeEntry = false;
|
|
17
18
|
let nativeRequest = 0;
|
|
19
|
+
let verticalSearch = false;
|
|
20
|
+
let tabVisibilityObserver;
|
|
18
21
|
return async (event, type) => {
|
|
19
22
|
const selector = type === TabBarSearchableType.Enter ? 'ion-fab-button' : 'ion-buttons[slot=start] ion-button';
|
|
20
23
|
if (!event.target?.closest(selector))
|
|
@@ -37,11 +40,28 @@ export const attachTabBarSearchable = (ionTabBar, ionFabButton, ionFooter) => {
|
|
|
37
40
|
return;
|
|
38
41
|
}
|
|
39
42
|
if (type === TabBarSearchableType.Enter) {
|
|
40
|
-
|
|
43
|
+
verticalSearch = !!ionTabBar.closest('ion-app.ios-theme-vertical-bars');
|
|
44
|
+
if (verticalSearch) {
|
|
45
|
+
const keepTabsVisible = () => {
|
|
46
|
+
if (ionTabBar.classList.contains('tab-bar-hidden') && ionTabBar.closest('ion-app.ios-theme-vertical-bars')) {
|
|
47
|
+
ionTabBar.classList.remove('tab-bar-hidden');
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
tabVisibilityObserver?.disconnect();
|
|
51
|
+
tabVisibilityObserver = new MutationObserver(keepTabsVisible);
|
|
52
|
+
tabVisibilityObserver.observe(ionTabBar, { attributes: true, attributeFilter: ['class'] });
|
|
53
|
+
keepTabsVisible();
|
|
54
|
+
}
|
|
55
|
+
resumeNative ?? (resumeNative = await suspendNativeUIShell([
|
|
56
|
+
...(verticalSearch ? [] : [ionTabBar]),
|
|
57
|
+
ionFooter,
|
|
58
|
+
ionFabButton.closest('ion-fab') ?? ionFabButton,
|
|
59
|
+
]));
|
|
41
60
|
try {
|
|
42
|
-
searchableEventCache = await enterEvent(event, ionTabBar, ionFabButton, ionFooter);
|
|
61
|
+
searchableEventCache = await enterEvent(event, ionTabBar, ionFabButton, ionFooter, verticalSearch);
|
|
43
62
|
}
|
|
44
63
|
catch (error) {
|
|
64
|
+
tabVisibilityObserver?.disconnect();
|
|
45
65
|
resumeNative();
|
|
46
66
|
resumeNative = undefined;
|
|
47
67
|
throw error;
|
|
@@ -49,10 +69,11 @@ export const attachTabBarSearchable = (ionTabBar, ionFabButton, ionFooter) => {
|
|
|
49
69
|
}
|
|
50
70
|
else if (searchableEventCache !== undefined) {
|
|
51
71
|
try {
|
|
52
|
-
await leaveEvent(event, searchableEventCache, ionTabBar, ionFabButton, ionFooter);
|
|
72
|
+
await leaveEvent(event, searchableEventCache, ionTabBar, ionFabButton, ionFooter, verticalSearch);
|
|
53
73
|
searchableEventCache = undefined;
|
|
54
74
|
}
|
|
55
75
|
finally {
|
|
76
|
+
tabVisibilityObserver?.disconnect();
|
|
56
77
|
resumeNative?.();
|
|
57
78
|
resumeNative = undefined;
|
|
58
79
|
}
|
|
@@ -62,7 +83,7 @@ export const attachTabBarSearchable = (ionTabBar, ionFabButton, ionFooter) => {
|
|
|
62
83
|
}
|
|
63
84
|
};
|
|
64
85
|
};
|
|
65
|
-
const enterEvent = async (event, ionTabBar, ionFabButton, ionFooter) => {
|
|
86
|
+
const enterEvent = async (event, ionTabBar, ionFabButton, ionFooter, verticalSearch) => {
|
|
66
87
|
if (!event.target?.closest('ion-fab-button')) {
|
|
67
88
|
throw throwErrorByFailedClickElement('ion-fab-button');
|
|
68
89
|
}
|
|
@@ -71,10 +92,13 @@ const enterEvent = async (event, ionTabBar, ionFabButton, ionFooter) => {
|
|
|
71
92
|
const colorSelected = references.selectedTabButton
|
|
72
93
|
? getComputedStyle(references.selectedTabButton).getPropertyValue('--color-selected').trim()
|
|
73
94
|
: '';
|
|
74
|
-
|
|
95
|
+
if (verticalSearch)
|
|
96
|
+
references.closeButtonIcon.setAttribute('name', 'close');
|
|
97
|
+
const tabAnimations = verticalSearch
|
|
98
|
+
? []
|
|
99
|
+
: [createTabBarAnimation(ionTabBar, references, sizes), createEffectAnimation(references, sizes)];
|
|
75
100
|
const searchContainerAnimation = createSearchContainerAnimation(references, sizes);
|
|
76
101
|
const closeButtonsAnimation = createCloseButtonsAnimation(references);
|
|
77
|
-
const tabBarAnimation = createTabBarAnimation(ionTabBar, references, sizes);
|
|
78
102
|
const fabButtonAnimation = createFabButtonAnimation(ionFabButton);
|
|
79
103
|
await createAnimation()
|
|
80
104
|
.delay(ANIMATION_DELAY_BASE)
|
|
@@ -83,22 +107,26 @@ const enterEvent = async (event, ionTabBar, ionFabButton, ionFooter) => {
|
|
|
83
107
|
.addElement(ionFooter)
|
|
84
108
|
.afterAddWrite(() => (ionFooter.style.pointerEvents = 'auto'))
|
|
85
109
|
.fromTo('opacity', '0.8', '1')
|
|
86
|
-
.addAnimation([
|
|
110
|
+
.addAnimation([...tabAnimations, fabButtonAnimation, searchContainerAnimation, closeButtonsAnimation])
|
|
87
111
|
.play();
|
|
88
112
|
return {
|
|
89
113
|
elementSizes: sizes,
|
|
90
114
|
colorSelected,
|
|
91
115
|
};
|
|
92
116
|
};
|
|
93
|
-
const leaveEvent = async (event, searchableEventCache, ionTabBar, ionFabButton, ionFooter) => {
|
|
117
|
+
const leaveEvent = async (event, searchableEventCache, ionTabBar, ionFabButton, ionFooter, verticalSearch) => {
|
|
94
118
|
if (!event.target?.closest('ion-buttons[slot=start] ion-button')) {
|
|
95
119
|
throw throwErrorByFailedClickElement('ion-buttons[slot=start] ion-button');
|
|
96
120
|
}
|
|
97
121
|
const references = getElementReferences(ionTabBar, ionFooter);
|
|
98
|
-
const
|
|
122
|
+
const tabAnimations = verticalSearch
|
|
123
|
+
? []
|
|
124
|
+
: [
|
|
125
|
+
createReverseTabBarAnimation(ionTabBar, references, searchableEventCache.elementSizes),
|
|
126
|
+
createReverseEffectAnimation(references, searchableEventCache.elementSizes, searchableEventCache.colorSelected),
|
|
127
|
+
];
|
|
99
128
|
const searchContainerAnimation = createReverseSearchContainerAnimation(references, searchableEventCache.elementSizes);
|
|
100
129
|
const closeButtonsAnimation = createReverseCloseButtonsAnimation(references);
|
|
101
|
-
const tabBarAnimation = createReverseTabBarAnimation(ionTabBar, references, searchableEventCache.elementSizes);
|
|
102
130
|
const fabButtonAnimation = createReverseFabButtonAnimation(ionFabButton, searchableEventCache.elementSizes);
|
|
103
131
|
await createAnimation()
|
|
104
132
|
.delay(ANIMATION_DELAY_BASE)
|
|
@@ -107,6 +135,6 @@ const leaveEvent = async (event, searchableEventCache, ionTabBar, ionFabButton,
|
|
|
107
135
|
.addElement(ionFooter)
|
|
108
136
|
.afterAddWrite(() => (ionFooter.style.pointerEvents = 'none'))
|
|
109
137
|
.fromTo('opacity', '1', '0')
|
|
110
|
-
.addAnimation([
|
|
138
|
+
.addAnimation([...tabAnimations, fabButtonAnimation, searchContainerAnimation, closeButtonsAnimation])
|
|
111
139
|
.play();
|
|
112
140
|
};
|
package/docs/native-ui-shell.md
CHANGED
|
@@ -90,13 +90,15 @@ The native host accepts input only within native controls. Tab interaction and a
|
|
|
90
90
|
|
|
91
91
|
## Searchable tabs
|
|
92
92
|
|
|
93
|
-
Existing `attachTabBarSearchable(tabBar, fabButton, footer)` registrations automatically use native search when their bottom tab bar and glass search controls are supported. No new component option, route, native setup or page listener is required. Ordinary tab bars continue using `UITabBar`; searchable groups use a persistent `UITabBarController`, `UITab` / `UISearchTab`, and `UISearchController`. The original Capacitor WebView continues to render results and handle navigation.
|
|
93
|
+
Existing `attachTabBarSearchable(tabBar, fabButton, footer)` registrations automatically use native search when their bottom tab bar and glass search controls are supported. No new component option, route, native setup or page listener is required. Ordinary tab bars continue using `UITabBar`; horizontal searchable groups use a persistent `UITabBarController`, `UITab` / `UISearchTab`, and `UISearchController`. The original Capacitor WebView continues to render results and handle navigation.
|
|
94
|
+
|
|
95
|
+
With vertical bars, the native rail keeps every tab and its selection. SwiftUI’s `searchable` and `searchToolbarBehavior(.minimize)` provide the system search button, field, dismissal control and transitions within the same native navigation surface. Apple determines their placement for the current device layout and focuses the native field when system search opens. Input, focus, clear and submit events use the same bridge as horizontal search. When native search is unavailable, the Web search presentation also preserves vertical tabs.
|
|
94
96
|
|
|
95
97
|
Search registration does not bypass placement restrictions. Its searchbar and close button must be in fixed footer toolbars. Its trigger must belong to an `ion-fab[slot="fixed"]` directly inside `ion-content`, or directly on the existing non-scrolling `.ion-page` layout. A wrapper inside scrolling content is not a fixed slot.
|
|
96
98
|
|
|
97
|
-
|
|
99
|
+
For horizontal tabs, while a registration is alive, Native UI Shell keeps the searchable controller even when the page is transitioning or temporarily unavailable (`available: false`), instead of demoting back to a separate ordinary control identity. Resting chrome (including Album before search opens) uses the same `UITabBar` + `ShellTabBar.fit` path as ordinary tabs, with the search trigger pinned to the FAB when available; `UISearchTab` is shown only while the search session is active. Flipping availability or entering/leaving search crossfades between those layers. Register before the destination page finishes entering (for example in `ionViewWillEnter`) so the first visit does not paint ordinary tabs and then swap.
|
|
98
100
|
|
|
99
|
-
|
|
101
|
+
With horizontal tabs, opening search preserves the selected Ionic tab and does not automatically show the keyboard (`automaticallyActivatesSearch` stays off). Tap the field or call `ion-searchbar.setFocus()` for the keyboard. While search is active, Native UI Shell freezes Web layout projection and holds Capacitor Keyboard resize at `none`; UIKit owns tab/search chrome (no Ionic `fit` mid-session). Leave re-fits ordinary tabs to `ion-tab-bar` and search to the FAB. Ordinary native tabs keep an optimistic selection until the Web `selected` state catches up. Input events and application `value` updates continue across the bridge until search closes. The trigger's resolved SVG and the search icon are projected from Ionic, including `ion-icon name`.
|
|
100
102
|
|
|
101
103
|
Native edits pass through Ionic's input handlers, preserving `ionInput` debounce, `ionChange`, `ionFocus`, `ionBlur`, and `ionClear`. Programmatic `value` changes do not emit `ionInput`; synchronous application corrections and stale native input are distinguished. Native editing owns marked text and the caret. Returning through the footer's close action retains the value and does not emit `ionCancel` or `ionClear`.
|
|
102
104
|
|
|
@@ -35,7 +35,7 @@ final class ShellSearchHost: UIView {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
// Preserve UISearchBar's delegate while returning its clear action to Ionic.
|
|
38
|
-
|
|
38
|
+
final class ShellSearchInputDelegate: NSObject, UITextFieldDelegate {
|
|
39
39
|
weak var original: UITextFieldDelegate?
|
|
40
40
|
var clear: (() -> Void)?
|
|
41
41
|
override func responds(to selector: Selector!) -> Bool {
|
|
@@ -6,6 +6,7 @@ protocol ShellVerticalBarsControlling: AnyObject {
|
|
|
6
6
|
var view: UIView { get }
|
|
7
7
|
func attach(to owner: UIViewController, in parent: UIView)
|
|
8
8
|
@MainActor func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String)
|
|
9
|
+
var ownsKeyboardChrome: Bool { get }
|
|
9
10
|
func detach()
|
|
10
11
|
}
|
|
11
12
|
|
|
@@ -30,6 +31,7 @@ final class ShellVerticalBarsModel: ObservableObject {
|
|
|
30
31
|
let slot: ShellControl.ToolbarSlot?
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
@Published var search: ShellVerticalSearchModel?
|
|
33
35
|
@Published var back: Item?
|
|
34
36
|
@Published var groups: [Group] = []
|
|
35
37
|
@Published var tabs: [Item] = []
|
|
@@ -163,6 +165,7 @@ private struct ShellVerticalBarsView: View {
|
|
|
163
165
|
}
|
|
164
166
|
}
|
|
165
167
|
}
|
|
168
|
+
.modifier(ShellVerticalBarsSearch(model: model))
|
|
166
169
|
.modifier(ShellVerticalBarsCompression())
|
|
167
170
|
.background(Color.clear)
|
|
168
171
|
}
|
|
@@ -179,6 +182,16 @@ private struct ShellVerticalBarsPage: View {
|
|
|
179
182
|
}
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
@available(iOS 26.0, *)
|
|
186
|
+
private struct ShellVerticalBarsSearch: ViewModifier {
|
|
187
|
+
@ObservedObject var model: ShellVerticalBarsModel
|
|
188
|
+
@ViewBuilder func body(content: Content) -> some View {
|
|
189
|
+
if let search = model.search {
|
|
190
|
+
content.modifier(ShellVerticalSearchModifier(model: search))
|
|
191
|
+
} else { content }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
182
195
|
@available(iOS 26.0, *)
|
|
183
196
|
private struct ShellVerticalBarsCompression: ViewModifier {
|
|
184
197
|
@ViewBuilder func body(content: Content) -> some View {
|
|
@@ -271,9 +284,11 @@ private struct ShellVerticalBarsLegacyToolbar: ViewModifier {
|
|
|
271
284
|
final class ShellVerticalBarsController: ShellVerticalBarsControlling {
|
|
272
285
|
private final class TransparentHostingController<Content: View>: UIHostingController<Content> {
|
|
273
286
|
var railEdge = "right"
|
|
287
|
+
var layoutSearch: (() -> Void)?
|
|
274
288
|
override func viewDidLayoutSubviews() {
|
|
275
289
|
super.viewDidLayoutSubviews()
|
|
276
290
|
makeFullSizeSurfacesTransparent(in: view)
|
|
291
|
+
layoutSearch?()
|
|
277
292
|
}
|
|
278
293
|
|
|
279
294
|
private func makeFullSizeSurfacesTransparent(in surface: UIView) {
|
|
@@ -323,7 +338,7 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling {
|
|
|
323
338
|
private func containsBarSurface(in view: UIView, at point: CGPoint) -> Bool {
|
|
324
339
|
guard !view.isHidden, view.alpha > 0.05 else { return false }
|
|
325
340
|
let name = NSStringFromClass(type(of: view))
|
|
326
|
-
if (name.contains("TabBar") || name.contains("Platter") || name.contains("Pocket") || name.contains("Sidebar")),
|
|
341
|
+
if (name.contains("TabBar") || name.contains("Platter") || name.contains("Pocket") || name.contains("Sidebar") || view is UISearchBar || view is UINavigationBar),
|
|
327
342
|
convert(view.bounds, from: view).contains(point) { return true }
|
|
328
343
|
return view.subviews.contains { containsBarSurface(in: $0, at: point) }
|
|
329
344
|
}
|
|
@@ -333,14 +348,20 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling {
|
|
|
333
348
|
private lazy var controller = TransparentHostingController(rootView: ShellVerticalBarsView(model: model))
|
|
334
349
|
private let container = RailContainer()
|
|
335
350
|
private weak var owner: UIViewController?
|
|
351
|
+
private let changed: (String, ShellSearchPhase, String, Bool, Int) -> Int
|
|
336
352
|
|
|
337
353
|
var view: UIView { container }
|
|
338
354
|
|
|
339
|
-
|
|
355
|
+
var ownsKeyboardChrome: Bool { model.search?.ownsKeyboardChrome == true }
|
|
356
|
+
|
|
357
|
+
init(activate: @escaping (String) -> Void,
|
|
358
|
+
changed: @escaping (String, ShellSearchPhase, String, Bool, Int) -> Int) {
|
|
359
|
+
self.changed = changed
|
|
340
360
|
model.activate = activate
|
|
341
361
|
container.backgroundColor = .clear
|
|
342
362
|
controller.view.backgroundColor = .clear
|
|
343
363
|
controller.view.isOpaque = false
|
|
364
|
+
controller.layoutSearch = { [weak self] in self?.model.search?.configureField() }
|
|
344
365
|
}
|
|
345
366
|
|
|
346
367
|
func attach(to owner: UIViewController, in parent: UIView) {
|
|
@@ -361,6 +382,10 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling {
|
|
|
361
382
|
container.railEdge = edge
|
|
362
383
|
controller.railEdge = edge
|
|
363
384
|
let tabsAppear = model.tabs.isEmpty && controls.contains { $0.kind == .tabBar && !$0.items.isEmpty }
|
|
385
|
+
if let search = controls.first(where: { $0.search != nil })?.search {
|
|
386
|
+
if model.search?.configuration.id == search.id { model.search?.apply(search) }
|
|
387
|
+
else { model.search = ShellVerticalSearchModel(search, in: container, activate: model.activate, changed: changed) }
|
|
388
|
+
} else { model.search = nil }
|
|
364
389
|
model.apply(controls, rendering: rendering)
|
|
365
390
|
if tabsAppear { ShellCrossfade.enter(container, duration: ShellCrossfade.duration(180)) }
|
|
366
391
|
controller.overrideUserInterfaceStyle = controls.contains(where: \.dark) ? .dark : .light
|
|
@@ -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 =
|
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rdlabo/ionic-theme-ios27",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "1.2.0-beta.
|
|
4
|
+
"version": "1.2.0-beta.pr233.sha647eef34274b",
|
|
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
|
-
|
|
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 = {
|
|
@@ -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
|
};
|