Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions demo/e2e/native-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4787,3 +4787,62 @@ test('vertical group resizing preserves the following surface and its action ide
await activate(page, 'Following');
await expect(page.locator('#following-group')).toHaveAttribute('data-clicked', 'true');
});

for (const vertical of [false, true]) {
test(`${vertical ? 'Vertical Bars' : 'Horizontal'} tabs use the applied selection color across overrides and dark mode`, async ({
page,
}) => {
await page.setViewportSize({ width: vertical ? 700 : 402, height: 900 });
await page.emulateMedia({ colorScheme: 'light' });
await mockNative(page, false, vertical ? 'trailing' : null);
await page.goto('/main/index');
if (vertical) await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars'));
await page.addStyleTag({
content: `ion-tab-bar {
--ion-color-primary: #800080; --ion-color-primary-rgb: 1, 2, 3;
--ion-tab-bar-color-selected: var(--ion-color-primary);
}`,
});
const tint = () =>
page.evaluate(
() =>
Capacitor.registerPlugin<NativeUIMock>('NativeUI')
.updates.at(-1)
?.controls.find((control) => control.kind === 'tab-bar')?.tabTint,
);
await expect.poll(tint).toBe('rgb(128, 0, 128)');
await page.addStyleTag({
content: `ion-tab-bar { --color-selected: orange; }
ion-tab-button[tab="docs"] { color-scheme: light dark; --color-selected: light-dark(blue, yellow); }`,
});
await expect.poll(tint).toBe('rgb(255, 165, 0)');
await activate(page, 'Docs');
await expect(page).toHaveURL('/main/docs');
await expect.poll(tint).toBe('rgb(0, 0, 255)');
await page.emulateMedia({ colorScheme: 'dark' });
await expect.poll(tint).toBe('rgb(255, 255, 0)');
await page.emulateMedia({ colorScheme: 'light' });
await expect.poll(tint).toBe('rgb(0, 0, 255)');
await page.locator('ion-tab-button[tab="docs"]').evaluate((tab) => {
tab.style.setProperty('--color-selected', 'currentColor');
});
await page.locator('ion-tab-bar').evaluate((bar) => {
bar.style.color = 'rgb(200, 0, 0)';
});
await expect.poll(tint).toBe('rgb(200, 0, 0)');
await activate(page, 'Index');
await expect(page).toHaveURL('/main/index');
await expect.poll(tint).toBe('rgb(255, 165, 0)');
await page.locator('ion-tab-bar').evaluate((bar) => bar.style.setProperty('--color-selected', 'rgba(120, 40, 80, 0.8)'));
await expect.poll(tint).toBe('rgba(120, 40, 80, 0.8)');
await page.locator('ion-tab-bar').evaluate((bar: HTMLIonTabBarElement) => {
bar.color = 'primary';
});
await expect(page.locator('ion-tab-bar')).toHaveAttribute('data-native-ui-projected', '');
await expect.poll(tint).toBeUndefined();
await page.locator('ion-tab-bar').evaluate((bar: HTMLIonTabBarElement) => {
bar.color = undefined;
});
await expect.poll(tint).toBe('rgba(120, 40, 80, 0.8)');
});
}
2 changes: 2 additions & 0 deletions packages/capacitor-native-ui/docs/native-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ While enabled, Native UI suppresses the WebView’s top scroll-edge effect becau

The native search controller remains visible over its own keyboard. Other projected controls are hidden while a Web input opens the keyboard. UIKit's own accessibility and Reduce Motion behavior apply to the standard controls; full VoiceOver traversal is not a verified parity guarantee.

Native tabs, including searchable tabs and Vertical Bars, use the selected Ionic tab button's computed foreground color as the native selection tint. This follows `--color-selected`, per-tab overrides and light/dark theme changes without requiring a separate RGB palette token. Without a selected tab or a supported computed RGB color, they use the system tint. Tab bars with an Ionic `color` palette also keep the system tint: their Web contrast color assumes a colored background that the native glass does not reproduce. Normal and disabled colors, labels and glass backgrounds follow the native design; colored Web backgrounds are not reproduced.

## FAB behavior

FABs retain Ionic's `activated`, per-child `show`, `close()` and original click handlers. Multiple lists, initial expansion, small buttons and `edge` use each button's measured layout. The native side reflects Ionic's staggered visibility without adding another timer or open/close controller. Main-button icon changes crossfade using the resolved `closeIcon`; Reduce Motion disables this crossfade. Native FAB button instances persist across open/close updates. The source FAB stays projected throughout ordinary opening and closing.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ final class NativeUISearchController: UITabBarController, UITabBarControllerDele
let available = configuration.available
let active = available && configuration.active
let wanted = wantedSession(active: active, focused: configuration.focused)
tabBar.tintColor = snapshot.tabTint.map { rendering.color($0) }
let items = snapshot.items
let ids = items.map(\.id)
for id in Array(ordinary.keys) where !ids.contains(id) { ordinary.removeValue(forKey: id) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ enum NativeUITabBar {

static func update(_ tabBar: UITabBar, node: NativeUIControl, rendering: NativeUIControlRendering,
pendingSelection: inout PendingSelection?) {
tabBar.tintColor = node.tabTint.map { rendering.color($0) }
let items = node.items
let ids = items.map(\.id)
var needsTitleWarmup = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ final class NativeUIVerticalBarsModel: ObservableObject {
@Published var navigation: [String] = []
private var pageGroups: [String: [Group]] = [:]
@Published var groups: [Group] = []
@Published var tabTint: String?
@Published var tabs: [Item] = []
@Published var selection = ""
var activate: (String) -> Void = { _ in }
Expand Down Expand Up @@ -82,6 +83,7 @@ final class NativeUIVerticalBarsModel: ObservableObject {
}
// Keep all snapshot publications in this transaction. A later unanimated
// accessory/tab publication can cancel the toolbar animation.
tabTint = controls.first(where: { $0.kind == .tabBar })?.tabTint
tabs = controls.first(where: { $0.kind == .tabBar })?.items.map { item($0) } ?? []
reconcileSelection(now: now)
}
Expand Down Expand Up @@ -429,10 +431,12 @@ final class NativeUIVerticalBarsController: NativeUIVerticalBarsControlling {
private final class TransparentHostingController<Content: View>: UIHostingController<Content> {
var railEdge = "right"
var layoutSearch: (() -> Void)?
var layoutTabs: (() -> Void)?
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
makeFullSizeSurfacesTransparent(in: view)
layoutSearch?()
layoutTabs?()
}

private func makeFullSizeSurfacesTransparent(in surface: UIView) {
Expand Down Expand Up @@ -542,6 +546,27 @@ final class NativeUIVerticalBarsController: NativeUIVerticalBarsControlling {
controller.view.backgroundColor = .clear
controller.view.isOpaque = false
controller.layoutSearch = { [weak self] in self?.model.search?.configureField() }
controller.layoutTabs = { [weak self] in self?.applyTabTint() }
}

private weak var tintedTabBar: UITabBar?
private var appliedTabTint: String?

private func applyTabTint() {
let tint = model.tabTint.map { NativeUIRendering.color($0) }
func visit(_ owner: UIViewController) {
if let tabs = owner as? UITabBarController {
// Scope the accent to tabs, without recoloring toolbars or search.
if tintedTabBar !== tabs.tabBar || appliedTabTint != model.tabTint || (tint != nil && tabs.tabBar.tintColor != tint) {
tabs.tabBar.tintColor = tint
tintedTabBar = tabs.tabBar
appliedTabTint = model.tabTint
}
return
}
owner.children.forEach(visit)
}
visit(controller)
}

@MainActor func attach(to owner: UIViewController, in parent: UIView) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,13 @@ struct NativeUIControl: Decodable, Equatable {
let items: [NativeUIItem]
let dark: Bool
let rtl: Bool
let tabTint: String?
let tabBarAnchor: NativeUITabBar.Anchor?
let search: NativeUISearch?
let extensionContent: NativeUIExtensionContent?

private enum CodingKeys: String, CodingKey {
case id, kind, placement, toolbarSlot, transitionId, items, dark, rtl, tabBarAnchor, search
case id, kind, placement, toolbarSlot, transitionId, items, dark, rtl, tabBarAnchor, tabTint, search
case extensionContent = "extension"
}

Expand All @@ -101,6 +102,7 @@ struct NativeUIControl: Decodable, Equatable {
items = try values.decode([NativeUIItem].self, forKey: .items)
dark = try values.decode(Bool.self, forKey: .dark)
rtl = try values.decode(Bool.self, forKey: .rtl)
tabTint = try values.decodeIfPresent(String.self, forKey: .tabTint)
tabBarAnchor = try values.decodeIfPresent(NativeUITabBar.Anchor.self, forKey: .tabBarAnchor)
search = try values.decodeIfPresent(NativeUISearch.self, forKey: .search)
extensionContent = try values.decodeIfPresent(NativeUIExtensionContent.self, forKey: .extensionContent)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,74 @@ import Capacitor
@testable import NativeUIPlugin

final class NativeUISnapshotTests: XCTestCase {
@MainActor func testHorizontalTabSelectionTintUpdatesAndResetsWithAndWithoutSearch() throws {
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") }
let rendering = NativeUIControlRendering()
let bar = UITabBar()
let controller = NativeUISearchController()
controller.loadViewIfNeeded()
let plainSystemTint = bar.tintColor
let searchSystemTint = controller.tabBar.tintColor
let frame = CGRect(x: 0, y: 0, width: 390, height: 844)
let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]),
"closeId": "close", "active": false, "available": true, "focused": false,
"value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0]
for (index, tint) in (["rgb(255, 0, 0)", "rgb(128, 0, 128)", nil] as [String?]).enumerated() {
var value = control(["kind": "tab-bar", "search": search, "items": [
item(["id": "first", "selected": index % 2 == 0]),
item(["id": "second", "selected": index % 2 != 0]),
item(["id": "disabled", "disabled": true])]])
value["tabTint"] = tint
let node = try XCTUnwrap(decode([value]).controls.first)
NativeUITabBar.update(bar, node: node, rendering: rendering)
_ = controller.apply(node, webFrame: frame,
barFrame: CGRect(x: 18, y: 730, width: 280, height: 62),
triggerFrame: CGRect(x: 320, y: 730, width: 56, height: 56), rendering: rendering)
XCTAssertEqual(bar.tintColor, tint.map { rendering.color($0) } ?? plainSystemTint)
XCTAssertEqual(controller.tabBar.tintColor, tint.map { rendering.color($0) } ?? searchSystemTint)
XCTAssertEqual(bar.selectedItem?.accessibilityIdentifier, index % 2 == 0 ? "first" : "second")
XCTAssertEqual(controller.selectedTab?.identifier, index % 2 == 0 ? "first" : "second")
XCTAssertEqual(bar.items?.last?.isEnabled, false)
XCTAssertEqual(controller.tabs.first { $0.identifier == "disabled" }?.isEnabled, false)
XCTAssertNil(bar.items?.first?.standardAppearance)
XCTAssertNotEqual(controller.view.tintColor, UIColor.red, "Do not tint the search field or content")
}
}

@MainActor func testVerticalTabSelectionTintUpdatesAndRestoresSystemAppearance() async throws {
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI tabs") }
let owner = UIViewController()
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = owner
window.makeKeyAndVisible()
let rail = NativeUIVerticalBarsController(activate: { _ in }, changed: { _, _, _, _, _ in 0 })
rail.attach(to: owner, in: owner.view)
defer { rail.detach(); window.isHidden = true }
func tabController(_ parent: UIViewController) -> UITabBarController? {
if let tabs = parent as? UITabBarController { return tabs }
return parent.children.lazy.compactMap { tabController($0) }.first
}
var systemTint: UIColor?
for (index, tint) in ([nil, "rgb(255, 0, 0)", "rgb(255, 0, 0)", "rgb(128, 0, 128)", nil] as [String?]).enumerated() {
var value = control(["kind": "tab-bar", "placement": "vertical-bars", "items": [
item(["id": "first", "label": "First", "selected": index % 2 == 0]),
item(["id": "second", "label": "Second", "selected": index % 2 != 0]),
item(["id": "disabled", "label": "Disabled", "disabled": true])]])
value["tabTint"] = tint
value["dark"] = index == 3
window.overrideUserInterfaceStyle = index == 3 ? .dark : .light
rail.apply(try decode([value]).controls, rendering: NativeUIControlRendering(), edge: "right", animated: false)
try await Task.sleep(nanoseconds: 500_000_000)
let tabs = try XCTUnwrap(tabController(owner))
if index == 0 { systemTint = tabs.tabBar.tintColor }
XCTAssertEqual(tabs.tabBar.tintColor, tint.map { NativeUIRendering.color($0) } ?? systemTint)
XCTAssertEqual(tabs.selectedIndex, index % 2)
XCTAssertEqual(tabs.traitCollection.userInterfaceStyle, index == 3 ? .dark : .light)
XCTAssertNil(tabs.tabBar.items?.first?.standardAppearance, "Keep the native background and state appearances")
XCTAssertNotEqual(owner.view.tintColor, UIColor.red, "The tab accent must not tint the surrounding controls")
}
}

@MainActor func testPopoverReopensAfterPresentationIdentityKeepsReplacedSource() async throws {
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires SwiftUI toolbar") }
let owner = UIViewController()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined

const candidate = createCandidate(element, tag, id);
const style = getComputedStyle(element);
// Palette contrast colors assume a colored Web background; keep native tint there.
const projectSelectionColor = !element.classList.contains('ion-color');
const rtl = style.direction === 'rtl';
const position = ['start', 'center', 'end'].find((value) => element.classList.contains(`tab-bar-position-${value}`));
candidate.control.tabBarAnchor = {
Expand All @@ -32,6 +34,9 @@ export const read = (element: HTMLElement, id: Identify): Candidate | undefined
for (const child of children) {
const item = appendItem(candidate, child, id);
if (!item) return;
// Read Ionic's applied selection color, after CSS inheritance and theme
// resolution, rather than requiring a particular palette token or format.
if (projectSelectionColor && item.selected && /^rgba?\(/.test(item.color)) candidate.control.tabTint = item.color;
const svg = child.querySelector<SVGElement>('svg') ?? child.querySelector('ion-icon')?.shadowRoot?.querySelector<SVGElement>('svg');
if (svg) {
item.iconPosition = 'top';
Expand Down
2 changes: 2 additions & 0 deletions packages/capacitor-native-ui/src/native/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ export interface NativeUIControl extends Frame {
items: NativeUIItem[];
dark: boolean;
rtl: boolean;
/** Applied selection tint for native tabs; other tab appearance remains system-owned. */
tabTint?: string;
tabBarAnchor?: { x: 0 | 0.5 | 1; y: 0 | 1 };
search?: NativeUISearch;
}
Expand Down
Loading