@rdlabo/ionic-theme-ios27 1.2.0-1 → 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/README.md +9 -0
- package/dist/css/components/ion-tabs.css +1 -1
- package/dist/css/ionic-theme-ios27.css +1 -1
- package/dist/css/vertical-bars.css +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -12
- package/dist/native/components/index.d.ts.map +1 -1
- package/dist/native/components/index.js +4 -1
- package/dist/native/components/ion-button.d.ts.map +1 -1
- package/dist/native/components/ion-button.js +3 -3
- package/dist/native/components/ion-buttons.d.ts.map +1 -1
- package/dist/native/components/ion-buttons.js +3 -2
- package/dist/native/components/searchable-tabs.d.ts.map +1 -1
- package/dist/native/components/searchable-tabs.js +6 -7
- package/dist/native/definitions.d.ts +8 -15
- package/dist/native/definitions.d.ts.map +1 -1
- package/dist/native/definitions.js +1 -6
- package/dist/native/index.d.ts +1 -2
- package/dist/native/index.d.ts.map +1 -1
- package/dist/native/index.js +64 -28
- package/dist/native/prehide.d.ts.map +1 -1
- package/dist/native/prehide.js +6 -4
- package/dist/native/runtime.d.ts.map +1 -1
- package/dist/native/runtime.js +40 -19
- package/dist/native/shared/candidate.d.ts.map +1 -1
- package/dist/native/shared/candidate.js +8 -0
- package/dist/native/shared/dom.d.ts.map +1 -1
- package/dist/native/shared/dom.js +11 -10
- package/dist/native/shared/modal.d.ts +8 -0
- package/dist/native/shared/modal.d.ts.map +1 -0
- package/dist/native/shared/modal.js +143 -0
- package/dist/native/vertical-bars-web.d.ts.map +1 -1
- package/dist/native/vertical-bars-web.js +11 -3
- package/dist/sheets-of-glass/animations.d.ts +13 -4
- package/dist/sheets-of-glass/animations.d.ts.map +1 -1
- package/dist/sheets-of-glass/animations.js +56 -40
- package/dist/sheets-of-glass/index.d.ts +1 -1
- package/dist/sheets-of-glass/index.d.ts.map +1 -1
- package/dist/sheets-of-glass/index.js +64 -20
- package/dist/tab-bar-searchable/index.d.ts.map +1 -1
- package/dist/tab-bar-searchable/index.js +39 -11
- package/dist/vertical-bars.d.ts +1 -2
- package/dist/vertical-bars.d.ts.map +1 -1
- package/dist/vertical-bars.js +0 -1
- package/docs/iphone-duo-with-original-theme.md +37 -15
- package/docs/iphone-duo.md +90 -47
- package/docs/migration.md +3 -3
- package/docs/native-ui-shell.md +12 -8
- package/docs/special-markup.md +0 -13
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +1 -1
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +50 -6
- package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalSearch.swift +118 -0
- package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +32 -179
- package/ios/Sources/IonicNativeUIShellPlugin/Shared/ShellSnapshot.swift +5 -0
- package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +67 -0
- package/package.json +1 -1
- package/src/index.ts +40 -9
- package/src/native/components/index.ts +6 -1
- package/src/native/components/ion-button.ts +4 -3
- package/src/native/components/ion-buttons.ts +4 -2
- package/src/native/components/searchable-tabs.ts +8 -11
- package/src/native/definitions.ts +11 -19
- package/src/native/index.ts +67 -30
- package/src/native/prehide.ts +7 -4
- package/src/native/runtime.ts +40 -18
- package/src/native/shared/candidate.ts +10 -0
- package/src/native/shared/dom.ts +11 -10
- package/src/native/shared/modal.ts +157 -0
- package/src/native/vertical-bars-web.ts +12 -3
- package/src/sheets-of-glass/animations.ts +66 -41
- package/src/sheets-of-glass/index.ts +82 -16
- package/src/styles/components/ion-tabs.scss +7 -4
- package/src/styles/vertical-bars.scss +110 -35
- package/src/tab-bar-searchable/index.ts +40 -9
- package/src/vertical-bars.ts +1 -2
|
@@ -22,6 +22,18 @@ final class ShellSnapshotTests: XCTestCase {
|
|
|
22
22
|
try JSValueDecoder().decode(ShellSnapshot.self, from: ["revision": 1, "viewportWidth": width, "controls": controls])
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
func testModalRailFrameIsOptionalAndRequiresPositiveBounds() throws {
|
|
26
|
+
var payload: JSObject = ["revision": 1, "viewportWidth": 466.0, "controls": [control()]]
|
|
27
|
+
let page = try JSValueDecoder().decode(ShellSnapshot.self, from: payload)
|
|
28
|
+
XCTAssertNil(page.verticalBarFrame)
|
|
29
|
+
for height in [339.0, 0.0, -1.0] {
|
|
30
|
+
payload["verticalBarFrame"] = ["x": 0.0, "y": 339.0, "width": 466.0, "height": height]
|
|
31
|
+
let modal = try JSValueDecoder().decode(ShellSnapshot.self, from: payload)
|
|
32
|
+
XCTAssertEqual(modal.isValid, height > 0)
|
|
33
|
+
XCTAssertEqual(modal.verticalBarFrame?.y, 339)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
@MainActor func testTabTypographyPreservesCSSWeightsAndSize() throws {
|
|
26
38
|
let weights: [UIFont.Weight] = [.ultraLight, .thin, .light, .regular, .medium, .semibold, .bold, .heavy, .black]
|
|
27
39
|
let tab = UITabBarItem()
|
|
@@ -37,6 +49,24 @@ final class ShellSnapshotTests: XCTestCase {
|
|
|
37
49
|
}
|
|
38
50
|
}
|
|
39
51
|
|
|
52
|
+
@MainActor func testVerticalBarsPreserveButtonAppearance() throws {
|
|
53
|
+
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive controls") }
|
|
54
|
+
let controls = try decode([control(["kind": "ion-buttons", "items": [
|
|
55
|
+
item(["backgroundColor": "rgb(0, 122, 255)"]),
|
|
56
|
+
item(["id": "outline", "borderColor": "rgb(255, 0, 0)", "borderWidth": 2.0])
|
|
57
|
+
]])]).controls
|
|
58
|
+
XCTAssertEqual(controls.first?.items.first?.content.backgroundColor, "rgb(0, 122, 255)")
|
|
59
|
+
let model = ShellVerticalBarsModel()
|
|
60
|
+
model.apply(controls, rendering: ShellRendering())
|
|
61
|
+
XCTAssertNotNil(model.groups.first?.items.first?.background)
|
|
62
|
+
XCTAssertNotNil(model.groups.first?.items.last?.borderColor)
|
|
63
|
+
XCTAssertEqual(model.groups.first?.items.last?.borderWidth, 2)
|
|
64
|
+
model.apply(try decode([control(["kind": "ion-button"])]).controls, rendering: ShellRendering())
|
|
65
|
+
XCTAssertNil(model.groups.first?.items.first?.background)
|
|
66
|
+
XCTAssertNil(model.groups.first?.items.first?.borderColor)
|
|
67
|
+
XCTAssertEqual(model.groups.first?.items.first?.borderWidth, 0)
|
|
68
|
+
}
|
|
69
|
+
|
|
40
70
|
@MainActor func testVerticalBarsTabOptimismWaitsForWebAndRollsBackWhenStale() throws {
|
|
41
71
|
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI adaptive tabs") }
|
|
42
72
|
func tabs(_ selected: String, includeRight: Bool = true) throws -> [ShellControl] {
|
|
@@ -577,4 +607,41 @@ final class ShellSnapshotTests: XCTestCase {
|
|
|
577
607
|
XCTAssertEqual(phases, ["input"])
|
|
578
608
|
}
|
|
579
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
|
+
|
|
580
647
|
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -17,24 +17,55 @@ export const registerTabBarEffect = (targetElement: HTMLElement): registeredEffe
|
|
|
17
17
|
const reducedMotion = win.matchMedia('(prefers-reduced-motion: reduce)');
|
|
18
18
|
const verticalBarsRoot = targetElement.closest('ion-app');
|
|
19
19
|
let effect: registeredEffect | undefined;
|
|
20
|
+
let placement: 'disabled' | 'horizontal' | 'left' | 'right' | undefined;
|
|
20
21
|
const update = () => {
|
|
21
|
-
effect?.destroy();
|
|
22
22
|
const isVertical =
|
|
23
|
-
!!verticalBarsRoot?.classList.contains('ios-theme-vertical-bars') &&
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
!!verticalBarsRoot?.classList.contains('ios-theme-vertical-bars') &&
|
|
24
|
+
targetElement.matches('ion-tabs > ion-tab-bar:not(.ios-theme-disabled, .ios26-disabled)') &&
|
|
25
|
+
!targetElement.closest('ion-menu, ion-modal, ion-popover');
|
|
26
|
+
const disabled = reducedMotion.matches || !targetElement.matches('.ios:not(.ios-theme-disabled, .ios26-disabled)');
|
|
27
|
+
const nextPlacement = disabled
|
|
28
|
+
? 'disabled'
|
|
29
|
+
: isVertical
|
|
30
|
+
? verticalBarsRoot?.classList.contains('ios-theme-vertical-bars-left')
|
|
31
|
+
? 'left'
|
|
32
|
+
: 'right'
|
|
33
|
+
: 'horizontal';
|
|
34
|
+
// Gesture/activation classes also change during interaction. Rebuild only when
|
|
35
|
+
// eligibility or placement changes, retaining cancellation when the rail changes sides.
|
|
36
|
+
if (placement === nextPlacement) return;
|
|
37
|
+
placement = nextPlacement;
|
|
38
|
+
effect?.destroy();
|
|
39
|
+
effect = disabled
|
|
40
|
+
? undefined
|
|
41
|
+
: registerEffect(
|
|
42
|
+
targetElement,
|
|
43
|
+
'ion-tab-button',
|
|
44
|
+
'tab-selected',
|
|
45
|
+
{
|
|
28
46
|
small: 'scale(1.1, 1)',
|
|
29
47
|
medium: 'scale(1.2)',
|
|
30
48
|
large: 'scale(1.15, 1.4)',
|
|
31
49
|
xlarge: 'scale(1.3)',
|
|
32
|
-
}
|
|
50
|
+
},
|
|
51
|
+
isVertical,
|
|
52
|
+
);
|
|
33
53
|
};
|
|
34
54
|
update();
|
|
35
55
|
reducedMotion.addEventListener('change', update);
|
|
36
|
-
const placementObserver = new win.MutationObserver(
|
|
37
|
-
|
|
56
|
+
const placementObserver = new win.MutationObserver((records) => {
|
|
57
|
+
if (
|
|
58
|
+
records.some((record) =>
|
|
59
|
+
record.type === 'attributes'
|
|
60
|
+
? record.target === targetElement || record.target === verticalBarsRoot
|
|
61
|
+
: [...Array.from(record.addedNodes), ...Array.from(record.removedNodes)].some((node) => node.contains(targetElement)),
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
update();
|
|
65
|
+
});
|
|
66
|
+
if (verticalBarsRoot)
|
|
67
|
+
placementObserver.observe(verticalBarsRoot, { attributes: true, attributeFilter: ['class'], childList: true, subtree: true });
|
|
68
|
+
placementObserver.observe(targetElement, { attributes: true, attributeFilter: ['class'] });
|
|
38
69
|
return {
|
|
39
70
|
destroy: () => {
|
|
40
71
|
reducedMotion.removeEventListener('change', update);
|
|
@@ -31,7 +31,12 @@ export const isVerticalBarsCandidate = isVerticalBarsSource;
|
|
|
31
31
|
|
|
32
32
|
export const readCandidate = (element: HTMLElement, id: Identify): Candidate | undefined => {
|
|
33
33
|
const verticalBars = isVerticalBarsCandidate(element);
|
|
34
|
-
if (
|
|
34
|
+
if (
|
|
35
|
+
(!element.classList.contains('ios') && !verticalBars) ||
|
|
36
|
+
!visible(element, verticalBars) ||
|
|
37
|
+
element.closest('ion-popover') ||
|
|
38
|
+
(element.closest('ion-modal') && !verticalBars)
|
|
39
|
+
)
|
|
35
40
|
return;
|
|
36
41
|
const style = getComputedStyle(element);
|
|
37
42
|
if (
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { modalUsesVerticalBars } from '../shared/modal';
|
|
1
2
|
import { createCandidate, appendItem } from '../shared/candidate';
|
|
2
3
|
import type { Candidate, Identify } from '../shared/candidate';
|
|
3
4
|
import { inFixedToolbar, isVerticalBarsToolbarAction, isVerticalBarsToolbarGroup } from '../shared/dom';
|
|
@@ -6,15 +7,15 @@ export const tag = 'ion-button';
|
|
|
6
7
|
|
|
7
8
|
export const read = (element: HTMLElement, id: Identify): Candidate | undefined => {
|
|
8
9
|
const button = element as HTMLIonButtonElement;
|
|
9
|
-
const verticalBars =
|
|
10
|
+
const verticalBars =
|
|
11
|
+
!element.closest('ion-menu, ion-popover') && modalUsesVerticalBars(element) && !!element.closest('ion-app.ios-theme-vertical-bars');
|
|
10
12
|
if (verticalBars && element.parentElement && isVerticalBarsToolbarGroup(element.parentElement)) return;
|
|
11
13
|
const fill = button.fill ?? 'default';
|
|
12
14
|
if (
|
|
13
15
|
!inFixedToolbar(element) ||
|
|
14
16
|
(verticalBars && !isVerticalBarsToolbarAction(element)) ||
|
|
15
17
|
(!verticalBars && fill !== 'default') ||
|
|
16
|
-
(verticalBars &&
|
|
17
|
-
button.classList.contains('ion-color')
|
|
18
|
+
(!verticalBars && button.classList.contains('ion-color'))
|
|
18
19
|
)
|
|
19
20
|
return;
|
|
20
21
|
const candidate = createCandidate(element, tag, id);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { modalUsesVerticalBars } from '../shared/modal';
|
|
1
2
|
import { createCandidate, appendItem } from '../shared/candidate';
|
|
2
3
|
import type { Candidate, Identify } from '../shared/candidate';
|
|
3
4
|
import { childElements, verticalBarsToolbarActions, inFixedToolbar, isVerticalBarsToolbarGroup } from '../shared/dom';
|
|
@@ -10,7 +11,8 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined
|
|
|
10
11
|
if (!inFixedToolbar(element)) return;
|
|
11
12
|
let children = childElements(element);
|
|
12
13
|
if (children.length === 1) return menuButton.read(element, id);
|
|
13
|
-
const verticalBars =
|
|
14
|
+
const verticalBars =
|
|
15
|
+
!element.closest('ion-menu, ion-popover') && modalUsesVerticalBars(element) && !!element.closest('ion-app.ios-theme-vertical-bars');
|
|
14
16
|
if (verticalBars && !isVerticalBarsToolbarGroup(element)) return;
|
|
15
17
|
if (verticalBars) children = verticalBarsToolbarActions(element);
|
|
16
18
|
if (
|
|
@@ -19,7 +21,7 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined
|
|
|
19
21
|
(child) =>
|
|
20
22
|
!child.matches(`${menuButton.tag}${verticalBars ? '' : '.ios'}`) &&
|
|
21
23
|
(!child.matches(`ion-button${verticalBars ? '' : '.ios.button-clear'}`) ||
|
|
22
|
-
!
|
|
24
|
+
(!verticalBars && (child as HTMLIonButtonElement).fill !== 'clear')),
|
|
23
25
|
)
|
|
24
26
|
)
|
|
25
27
|
return;
|
|
@@ -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'));
|
|
@@ -44,14 +44,10 @@ export type VerticalBarEdge = 'leading' | 'trailing' | null;
|
|
|
44
44
|
|
|
45
45
|
export interface VerticalBarPlacement {
|
|
46
46
|
edge: VerticalBarEdge;
|
|
47
|
-
/**
|
|
48
|
-
inset
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
export enum HingeStatus {
|
|
52
|
-
Closed = 'closed',
|
|
53
|
-
PartiallyOpen = 'partiallyOpen',
|
|
54
|
-
FullyOpen = 'fullyOpen',
|
|
47
|
+
/** Explicit rail width in CSS pixels; omitted to use the stylesheet's safe-area rules. */
|
|
48
|
+
inset?: number;
|
|
49
|
+
/** Native logical edge reported by the application's device plugin. Null or an unregistered edge uses a Web rail in verticalBarsOnly mode, or the ordinary Native UI Shell layout otherwise. Omission keeps the last supplied value. */
|
|
50
|
+
nativeEdge?: VerticalBarEdge;
|
|
55
51
|
}
|
|
56
52
|
|
|
57
53
|
export interface VerticalControlAreaHandle extends NativeUIShellHandle {
|
|
@@ -86,6 +82,9 @@ export interface ShellItem extends Frame {
|
|
|
86
82
|
fontSize: number;
|
|
87
83
|
fontWeight: number;
|
|
88
84
|
color: string;
|
|
85
|
+
backgroundColor?: string;
|
|
86
|
+
borderColor?: string;
|
|
87
|
+
borderWidth?: number;
|
|
89
88
|
badge?: ShellBadge;
|
|
90
89
|
icon?: string;
|
|
91
90
|
iconWidth?: number;
|
|
@@ -144,6 +143,8 @@ export interface ShellSnapshot {
|
|
|
144
143
|
viewportWidth: number;
|
|
145
144
|
/** Physical side the native rail is drawn on; the runtime resolves the logical edge through the document direction. */
|
|
146
145
|
verticalBarEdge?: 'left' | 'right';
|
|
146
|
+
/** Visible foreground modal bounds; omitted for the full page. */
|
|
147
|
+
verticalBarFrame?: Frame;
|
|
147
148
|
controls: ShellControl[];
|
|
148
149
|
}
|
|
149
150
|
|
|
@@ -158,21 +159,12 @@ export interface WebViewMetrics {
|
|
|
158
159
|
radius: number;
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
export interface DeviceLayout {
|
|
162
|
-
placement: VerticalBarPlacement;
|
|
163
|
-
/** Fold hinge posture, or `null` when the device reports no hinge. */
|
|
164
|
-
hingeStatus: HingeStatus | null;
|
|
165
|
-
webViewMetrics: WebViewMetrics;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
162
|
export interface NativeUIShellPlugin {
|
|
169
163
|
configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>;
|
|
170
|
-
|
|
171
|
-
startDeviceLayoutMonitoring(): Promise<void>;
|
|
172
|
-
stopDeviceLayoutMonitoring(): Promise<void>;
|
|
164
|
+
getWebViewMetrics(): Promise<WebViewMetrics>;
|
|
173
165
|
update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>;
|
|
174
166
|
clear(options: { revision: number }): Promise<void>;
|
|
175
167
|
addListener(name: 'activate', listener: (event: ShellActivation) => void): Promise<PluginListenerHandle>;
|
|
176
168
|
addListener(name: 'search', listener: (event: ShellSearchEvent) => void): Promise<PluginListenerHandle>;
|
|
177
|
-
addListener(name: '
|
|
169
|
+
addListener(name: 'webViewMetricsChange', listener: (event: WebViewMetrics) => void): Promise<PluginListenerHandle>;
|
|
178
170
|
}
|
package/src/native/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
import { createRuntime } from './runtime';
|
|
13
13
|
import { createVerticalBarsWebProjection } from './vertical-bars-web';
|
|
14
14
|
import { prehideVerticalBarsToolbarSources } from './prehide';
|
|
15
|
+
import { observeVerticalBarsModals } from './shared/modal';
|
|
15
16
|
export type {
|
|
16
17
|
NativeUIShellComponent,
|
|
17
18
|
NativeUIShellControls,
|
|
@@ -19,13 +20,11 @@ export type {
|
|
|
19
20
|
NativeUIShellOptions,
|
|
20
21
|
NativeUIShellStatus,
|
|
21
22
|
NativeUIShellSuspension,
|
|
22
|
-
DeviceLayout,
|
|
23
23
|
VerticalBarEdge,
|
|
24
24
|
VerticalBarPlacement,
|
|
25
25
|
VerticalControlAreaHandle,
|
|
26
26
|
WebViewMetrics,
|
|
27
27
|
} from './definitions';
|
|
28
|
-
export { HingeStatus } from './definitions';
|
|
29
28
|
|
|
30
29
|
const plugin = registerPlugin<NativeUIShellPlugin>('IonicNativeUIShell');
|
|
31
30
|
export const IonicNativeUIShell = plugin;
|
|
@@ -97,8 +96,7 @@ const manage = (
|
|
|
97
96
|
|
|
98
97
|
/** Reads the current native WebView geometry and applies it to page transitions. */
|
|
99
98
|
export const configureNativeTransition = async (): Promise<WebViewMetrics> => {
|
|
100
|
-
const metrics =
|
|
101
|
-
typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? (await plugin.getDeviceLayout()).webViewMetrics : { radius: 0 };
|
|
99
|
+
const metrics = typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? await plugin.getWebViewMetrics() : { radius: 0 };
|
|
102
100
|
setConfig({ radius: metrics.radius });
|
|
103
101
|
return metrics;
|
|
104
102
|
};
|
|
@@ -109,6 +107,39 @@ const physicalVerticalBarEdge = (edge: Exclude<VerticalBarEdge, null>, rtl: bool
|
|
|
109
107
|
|
|
110
108
|
const elementRtl = (element: Element): boolean => element.closest('[dir]')?.getAttribute('dir') === 'rtl';
|
|
111
109
|
|
|
110
|
+
// Device facts are supplied by the application; the theme only compares placement.
|
|
111
|
+
const nativePlacements = new WeakMap<HTMLElement, { edge: VerticalBarEdge; rtl?: boolean }>();
|
|
112
|
+
const horizontalFallbackAttribute = 'data-native-ui-shell-vertical-bars-suspended';
|
|
113
|
+
|
|
114
|
+
// Keep the requested rail while ordinary Native UI Shell temporarily uses its
|
|
115
|
+
// horizontal layout. Removing the effective class restores normal measurements,
|
|
116
|
+
// toolbar ownership and safe areas throughout the existing rendering pipeline.
|
|
117
|
+
const observeNativeVerticalBarsLayout = (doc: Document): (() => void) => {
|
|
118
|
+
const reconcile = () => {
|
|
119
|
+
const app = doc.querySelector<HTMLElement>('ion-app');
|
|
120
|
+
if (!app) return;
|
|
121
|
+
const requested = app.classList.contains('ios-theme-vertical-bars') || app.hasAttribute(horizontalFallbackAttribute);
|
|
122
|
+
const suspended = requested && nativePlacements.get(app)?.edge == null;
|
|
123
|
+
if (app.hasAttribute(horizontalFallbackAttribute) !== suspended) app.toggleAttribute(horizontalFallbackAttribute, suspended);
|
|
124
|
+
if (app.classList.contains('ios-theme-vertical-bars') !== (requested && !suspended)) {
|
|
125
|
+
app.classList.toggle('ios-theme-vertical-bars', requested && !suspended);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const observer = new MutationObserver(reconcile);
|
|
129
|
+
observer.observe(doc.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
|
|
130
|
+
doc.defaultView?.addEventListener('nativeUIShellRefresh', reconcile);
|
|
131
|
+
reconcile();
|
|
132
|
+
return () => {
|
|
133
|
+
observer.disconnect();
|
|
134
|
+
doc.defaultView?.removeEventListener('nativeUIShellRefresh', reconcile);
|
|
135
|
+
const app = doc.querySelector<HTMLElement>(`ion-app[${horizontalFallbackAttribute}]`);
|
|
136
|
+
if (app) {
|
|
137
|
+
app.removeAttribute(horizontalFallbackAttribute);
|
|
138
|
+
app.classList.add('ios-theme-vertical-bars');
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
112
143
|
/**
|
|
113
144
|
* Applies one placement to the CSS layout and both Web/native projections.
|
|
114
145
|
* Pass `rtl` when the document direction is known; otherwise the nearest `dir` attribute is used.
|
|
@@ -117,11 +148,16 @@ export const setVerticalControlAreaPlacement = (placement: VerticalBarEdge | Ver
|
|
|
117
148
|
if (typeof document === 'undefined') return;
|
|
118
149
|
const app = document.querySelector<HTMLElement>('ion-app');
|
|
119
150
|
if (!app) throw new Error('Vertical Control Area requires ion-app');
|
|
120
|
-
const { edge, inset } = placement && typeof placement === 'object' ? placement : { edge: placement, inset: 0 };
|
|
151
|
+
const { edge, inset = 0 } = placement && typeof placement === 'object' ? placement : { edge: placement, inset: 0 };
|
|
152
|
+
if (placement && typeof placement === 'object' && placement.nativeEdge !== undefined) {
|
|
153
|
+
nativePlacements.set(app, { edge: placement.nativeEdge, rtl });
|
|
154
|
+
}
|
|
155
|
+
app.removeAttribute(horizontalFallbackAttribute);
|
|
121
156
|
app.classList.toggle('ios-theme-vertical-bars', edge !== null);
|
|
122
157
|
app.classList.toggle('ios-theme-vertical-bars-left', edge !== null && physicalVerticalBarEdge(edge, rtl ?? elementRtl(app)) === 'left');
|
|
123
158
|
if (edge && Number.isFinite(inset) && inset > 0) app.style.setProperty('--ios-theme-vertical-bars-native-inset', `${inset}px`);
|
|
124
159
|
else app.style.removeProperty('--ios-theme-vertical-bars-native-inset');
|
|
160
|
+
document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh'));
|
|
125
161
|
};
|
|
126
162
|
|
|
127
163
|
/** Call once at application startup. Ionic markup remains the source of truth. */
|
|
@@ -160,6 +196,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
|
|
|
160
196
|
);
|
|
161
197
|
if (active) return active;
|
|
162
198
|
activeConfiguration = configuration;
|
|
199
|
+
const stopModals = observeVerticalBarsModals(document);
|
|
163
200
|
const prehide =
|
|
164
201
|
options.controls === undefined || options.controls.toolbar === true ? prehideVerticalBarsToolbarSources(document) : undefined;
|
|
165
202
|
// A slow destroy of a superseded activation must not release a newer one.
|
|
@@ -174,38 +211,37 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
|
|
|
174
211
|
manage(createVerticalBarsWebProjection(document, options), {
|
|
175
212
|
reason,
|
|
176
213
|
suspend: () => prehide?.suspend(),
|
|
177
|
-
destroy: () =>
|
|
214
|
+
destroy: () => {
|
|
215
|
+
prehide?.stop();
|
|
216
|
+
stopModals();
|
|
217
|
+
},
|
|
178
218
|
release,
|
|
179
219
|
});
|
|
180
220
|
return (start = active =
|
|
181
221
|
(async () => {
|
|
182
222
|
if (Capacitor.getPlatform() !== 'ios') return fallback('Requires Capacitor iOS');
|
|
183
223
|
let runtime: NativeUIShellHandle | undefined;
|
|
184
|
-
let
|
|
185
|
-
let
|
|
224
|
+
let metricsListener: Awaited<ReturnType<typeof plugin.addListener>> | undefined;
|
|
225
|
+
let stopVerticalBarsLayout: (() => void) | undefined;
|
|
186
226
|
try {
|
|
187
|
-
if (!options.verticalBarsOnly) await configureNativeTransition().catch(() => undefined);
|
|
188
227
|
const capabilities = await plugin.configure({ verticalBarsOnly: options.verticalBarsOnly === true });
|
|
189
228
|
if (!capabilities.supported) return fallback('Requires iOS 26 or later');
|
|
190
|
-
|
|
229
|
+
if (!options.verticalBarsOnly) stopVerticalBarsLayout = observeNativeVerticalBarsLayout(document);
|
|
230
|
+
// The application owns device state and selects the rail through placement classes.
|
|
191
231
|
const nativeVerticalBars = () => {
|
|
192
|
-
const
|
|
193
|
-
if (!
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const domEdge = root.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right';
|
|
199
|
-
return nativeEdge === null || physicalVerticalBarEdge(nativeEdge, elementRtl(root)) === domEdge;
|
|
232
|
+
const app = document.querySelector<HTMLElement>('ion-app.ios-theme-vertical-bars');
|
|
233
|
+
if (!app) return false;
|
|
234
|
+
const reported = nativePlacements.get(app);
|
|
235
|
+
if (!reported?.edge) return false;
|
|
236
|
+
const physicalEdge = app.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right';
|
|
237
|
+
return physicalEdge === physicalVerticalBarEdge(reported.edge, reported.rtl ?? elementRtl(app));
|
|
200
238
|
};
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
});
|
|
208
|
-
nativeEdge = (await plugin.getDeviceLayout()).placement.edge;
|
|
239
|
+
if (!options.verticalBarsOnly) {
|
|
240
|
+
metricsListener = await plugin.addListener('webViewMetricsChange', (metrics) => {
|
|
241
|
+
setConfig({ radius: metrics.radius });
|
|
242
|
+
});
|
|
243
|
+
await configureNativeTransition().catch(() => undefined);
|
|
244
|
+
}
|
|
209
245
|
const native = await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true);
|
|
210
246
|
runtime = combine(
|
|
211
247
|
native,
|
|
@@ -215,15 +251,16 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise
|
|
|
215
251
|
suspend: () => prehide?.suspend(),
|
|
216
252
|
release,
|
|
217
253
|
destroy: async () => {
|
|
218
|
-
await
|
|
219
|
-
|
|
254
|
+
await metricsListener?.remove().catch(() => {});
|
|
255
|
+
stopVerticalBarsLayout?.();
|
|
220
256
|
prehide?.stop();
|
|
257
|
+
stopModals();
|
|
221
258
|
},
|
|
222
259
|
});
|
|
223
260
|
} catch (error) {
|
|
224
261
|
await runtime?.destroy();
|
|
225
|
-
await
|
|
226
|
-
|
|
262
|
+
await metricsListener?.remove().catch(() => {});
|
|
263
|
+
stopVerticalBarsLayout?.();
|
|
227
264
|
return fallback(error instanceof Error ? error.message : String(error));
|
|
228
265
|
}
|
|
229
266
|
})());
|
package/src/native/prehide.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { modalUsesVerticalBars } from './shared/modal';
|
|
1
2
|
import { LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE, LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE } from '@ionic/core';
|
|
2
3
|
import { VERTICAL_BARS_TRANSITION_CANCELED } from '../native-integration';
|
|
3
4
|
import {
|
|
@@ -17,7 +18,7 @@ import {
|
|
|
17
18
|
setVerticalBarsPlacement,
|
|
18
19
|
} from './shared/dom';
|
|
19
20
|
|
|
20
|
-
const overlays = 'ion-menu, ion-
|
|
21
|
+
const overlays = 'ion-menu, ion-popover';
|
|
21
22
|
const sourceSelector = 'ion-back-button, ion-menu-button, ion-buttons, ion-button';
|
|
22
23
|
// Realm- and teardown-safe: globals such as Element/HTMLElement may be gone
|
|
23
24
|
// when a queued mutation microtask or a stale listener still runs.
|
|
@@ -60,7 +61,7 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: ()
|
|
|
60
61
|
scopes.delete(scope);
|
|
61
62
|
};
|
|
62
63
|
const capture = (scope: HTMLElement) => {
|
|
63
|
-
if (!root()?.contains(scope) || scope.closest(overlays) || verticalBarsPages.isDeparted(scope)) return;
|
|
64
|
+
if (!root()?.contains(scope) || scope.closest(overlays) || !modalUsesVerticalBars(scope) || verticalBarsPages.isDeparted(scope)) return;
|
|
64
65
|
const owned = scopes.get(scope) ?? new Set<HTMLElement>();
|
|
65
66
|
const place = (element: HTMLElement, rail: boolean) => {
|
|
66
67
|
if (owned.has(element)) return;
|
|
@@ -73,7 +74,8 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: ()
|
|
|
73
74
|
};
|
|
74
75
|
const sources = scope.matches('ion-back-button') ? [scope] : Array.from(scope.querySelectorAll<HTMLElement>(sourceSelector));
|
|
75
76
|
sources.forEach((element) => {
|
|
76
|
-
if (element.closest(overlays) || (scope.matches('.ion-page') && routedPage(element) !== scope))
|
|
77
|
+
if (!modalUsesVerticalBars(element) || element.closest(overlays) || (scope.matches('.ion-page') && routedPage(element) !== scope))
|
|
78
|
+
return;
|
|
77
79
|
if (element.matches('ion-buttons')) {
|
|
78
80
|
const children = childElements(element);
|
|
79
81
|
const actions = children.filter((child) => eligible(child) && isVerticalBarsToolbarActionShape(child));
|
|
@@ -134,7 +136,7 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: ()
|
|
|
134
136
|
Array.from(scopes.keys()).forEach(release);
|
|
135
137
|
return;
|
|
136
138
|
}
|
|
137
|
-
for (const scope of scopes.keys()) if (!scope.isConnected) release(scope);
|
|
139
|
+
for (const scope of scopes.keys()) if (!scope.isConnected || !modalUsesVerticalBars(scope)) release(scope);
|
|
138
140
|
for (const [element, pending] of pendingBacks) if (element.shadowRoot || element.classList.contains('hydrated')) capture(pending.scope);
|
|
139
141
|
// Ionic inserts the destination as invisible before WillEnter. Hide its
|
|
140
142
|
// sources in that same mutation microtask; capture new DOM identities on active pages too.
|
|
@@ -193,6 +195,7 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: ()
|
|
|
193
195
|
for (const name of [LIFECYCLE_WILL_LEAVE, LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE])
|
|
194
196
|
doc.addEventListener(name, onPageLifecycle, { capture: true, signal: listeners.signal });
|
|
195
197
|
doc.addEventListener(VERTICAL_BARS_TRANSITION_CANCELED, onTransitionCanceled, { capture: true, signal: listeners.signal });
|
|
198
|
+
doc.defaultView?.addEventListener('nativeUIShellRefresh', reconcile, { signal: listeners.signal });
|
|
196
199
|
reconcile();
|
|
197
200
|
const mutationRelevant = (record: MutationRecord) => {
|
|
198
201
|
if (record.type === 'childList')
|