diff --git a/core/src/utils/native/status-bar.ts b/core/src/utils/native/status-bar.ts index c673a55394f..7505763ca99 100644 --- a/core/src/utils/native/status-bar.ts +++ b/core/src/utils/native/status-bar.ts @@ -1,7 +1,11 @@ +import type { SystemBars } from '@capacitor/core'; import type { StatusBarPlugin, Style as StatusBarStyle } from '@capacitor/status-bar'; import { getCapacitor } from './capacitor'; +type SystemBarsPlugin = typeof SystemBars; +type SystemBarsStyleOptions = Parameters[0]; + interface StyleOptions { style: StatusBarStyle; } @@ -12,29 +16,60 @@ export enum Style { Default = 'DEFAULT', } -export const StatusBar = { - getEngine(): StatusBarPlugin | undefined { - const capacitor = getCapacitor(); +/** + * The external @capacitor/status-bar plugin is preferred because it can + * report the current style. Capacitor 8+ ships the built-in SystemBars + * plugin, which is used when the external plugin is not installed. + */ +type ResolvedEngine = + | { kind: 'StatusBar'; plugin: StatusBarPlugin } + | { kind: 'SystemBars'; plugin: SystemBarsPlugin } + | undefined; - if (capacitor?.isPluginAvailable('StatusBar')) { - return capacitor.Plugins.StatusBar as StatusBarPlugin; - } - return undefined; +const resolveEngine = (): ResolvedEngine => { + const capacitor = getCapacitor(); + + if (capacitor?.isPluginAvailable('StatusBar')) { + return { kind: 'StatusBar', plugin: capacitor.Plugins.StatusBar as StatusBarPlugin }; + } + if (capacitor?.isPluginAvailable('SystemBars')) { + return { kind: 'SystemBars', plugin: capacitor.Plugins.SystemBars as SystemBarsPlugin }; + } + return undefined; +}; + +export const StatusBar = { + getEngine(): StatusBarPlugin | SystemBarsPlugin | undefined { + return resolveEngine()?.plugin; }, setStyle(options: StyleOptions) { - const engine = this.getEngine(); + const engine = resolveEngine(); if (!engine) { return; } - engine.setStyle(options); + if (engine.kind === 'SystemBars') { + /** + * Only restyle the status bar so the navigation/gesture bar is + * left untouched. String literals are used instead of Capacitor's + * runtime enums to avoid importing @capacitor/core at runtime. + */ + engine.plugin.setStyle({ style: options.style, bar: 'StatusBar' } as unknown as SystemBarsStyleOptions); + return; + } + + engine.plugin.setStyle(options); }, getStyle: async function (): Promise { - const engine = this.getEngine(); - if (!engine) { + const engine = resolveEngine(); + /** + * SystemBars has no getter, so DEFAULT (device appearance) is the + * style restored on dismiss. + */ + if (engine?.kind !== 'StatusBar') { return Style.Default; } - const { style } = await engine.getInfo(); + const { style } = await engine.plugin.getInfo(); return style; }, }; diff --git a/core/src/utils/native/test/status-bar.spec.ts b/core/src/utils/native/test/status-bar.spec.ts new file mode 100644 index 00000000000..7cf77222670 --- /dev/null +++ b/core/src/utils/native/test/status-bar.spec.ts @@ -0,0 +1,138 @@ +import { StatusBar, Style } from '../status-bar'; + +const createStatusBarPlugin = () => ({ + setStyle: jest.fn(), + getInfo: jest.fn().mockResolvedValue({ style: Style.Light, visible: true, overlays: false }), +}); + +/** + * Installs a fake window.Capacitor. `plugins` are registered under + * `Capacitor.Plugins`, and `isPluginAvailable` reports only the + * names listed in `available`. + */ +const mockCapacitor = ({ plugins, available }: { plugins: Record; available: string[] }) => { + (window as any).Capacitor = { + isPluginAvailable: (name: string) => available.includes(name), + Plugins: plugins, + }; +}; + +/** + * Assertions use raw style strings rather than the Style enum so they + * pin the values the native plugins actually receive. + */ +describe('StatusBar', () => { + afterEach(() => { + delete (window as any).Capacitor; + }); + + describe('when the StatusBar plugin is available', () => { + let statusBarPlugin: ReturnType; + + beforeEach(() => { + statusBarPlugin = createStatusBarPlugin(); + mockCapacitor({ plugins: { StatusBar: statusBarPlugin }, available: ['StatusBar'] }); + }); + + it('should forward the style to the StatusBar plugin', () => { + StatusBar.setStyle({ style: Style.Dark }); + + expect(statusBarPlugin.setStyle.mock.calls).toStrictEqual([[{ style: 'DARK' }]]); + }); + + it('should resolve getStyle to the style reported by the StatusBar plugin', async () => { + await expect(StatusBar.getStyle()).resolves.toBe('LIGHT'); + }); + }); + + describe('when Capacitor is present but the StatusBar plugin is not registered', () => { + beforeEach(() => { + mockCapacitor({ plugins: {}, available: [] }); + }); + + it('should not throw when setting the style', () => { + expect(() => StatusBar.setStyle({ style: Style.Dark })).not.toThrow(); + }); + + it('should resolve getStyle to DEFAULT', async () => { + await expect(StatusBar.getStyle()).resolves.toBe('DEFAULT'); + }); + }); + + describe('when the StatusBar plugin is registered but not available', () => { + let statusBarPlugin: ReturnType; + + beforeEach(() => { + /** + * The plugin is registered under Plugins but not available natively, + * so the wrapper must rely on isPluginAvailable rather than on the + * presence of Plugins.StatusBar. + */ + statusBarPlugin = createStatusBarPlugin(); + mockCapacitor({ plugins: { StatusBar: statusBarPlugin }, available: [] }); + }); + + it('should not call the StatusBar plugin when setting the style', () => { + expect(() => StatusBar.setStyle({ style: Style.Dark })).not.toThrow(); + + expect(statusBarPlugin.setStyle).not.toHaveBeenCalled(); + }); + + it('should resolve getStyle to DEFAULT without querying the StatusBar plugin', async () => { + await expect(StatusBar.getStyle()).resolves.toBe('DEFAULT'); + + expect(statusBarPlugin.getInfo).not.toHaveBeenCalled(); + }); + }); + + describe('when only the SystemBars plugin is available', () => { + let systemBarsPlugin: { setStyle: jest.Mock }; + + beforeEach(() => { + systemBarsPlugin = { setStyle: jest.fn() }; + mockCapacitor({ plugins: { SystemBars: systemBarsPlugin }, available: ['SystemBars'] }); + }); + + it('should style only the status bar through the SystemBars plugin', () => { + StatusBar.setStyle({ style: Style.Dark }); + + expect(systemBarsPlugin.setStyle.mock.calls).toStrictEqual([[{ style: 'DARK', bar: 'StatusBar' }]]); + }); + + it('should resolve getStyle to DEFAULT', async () => { + await expect(StatusBar.getStyle()).resolves.toBe('DEFAULT'); + }); + }); + + describe('when both the StatusBar and SystemBars plugins are available', () => { + let statusBarPlugin: ReturnType; + let systemBarsPlugin: { setStyle: jest.Mock }; + + beforeEach(() => { + statusBarPlugin = createStatusBarPlugin(); + systemBarsPlugin = { setStyle: jest.fn() }; + mockCapacitor({ + plugins: { StatusBar: statusBarPlugin, SystemBars: systemBarsPlugin }, + available: ['StatusBar', 'SystemBars'], + }); + }); + + it('should only call the StatusBar plugin', async () => { + StatusBar.setStyle({ style: Style.Dark }); + + await expect(StatusBar.getStyle()).resolves.toBe('LIGHT'); + expect(statusBarPlugin.setStyle.mock.calls).toStrictEqual([[{ style: 'DARK' }]]); + expect(systemBarsPlugin.setStyle).not.toHaveBeenCalled(); + }); + }); + + describe('when Capacitor is not present', () => { + it('should not throw when setting the style', () => { + expect(() => StatusBar.setStyle({ style: Style.Dark })).not.toThrow(); + }); + + it('should resolve getStyle to DEFAULT', async () => { + await expect(StatusBar.getStyle()).resolves.toBe('DEFAULT'); + }); + }); +});