Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ Add an event handler that is called with `{colorScheme: "dark" | "light"}` when
{% call macro.prop('getColorScheme', '() => ("dark" | "light")') %}
You can use the Appearance module to determine if the user prefers a dark color scheme. Although the color scheme is available immediately, this may change (e.g. scheduled color scheme change at sunrise or sunset). Any rendering logic or styles that depend on the user preferred color scheme should try to call this function on every render, rather than caching the value.
{% endcall %}

{% call macro.prop('setColorScheme', '("dark" | "light" | "auto") => void') %}
Override the color scheme for the application. Use `"auto"` to remove the override and follow the user's preferred color scheme again.
{% endcall %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

function createMediaQueryList(initialMatches = false) {
let matches = initialMatches;
const listeners = new Set();

return {
addListener(listener) {
listeners.add(listener);
},
emit(nextMatches) {
matches = nextMatches;
listeners.forEach((listener) => listener({ matches }));
},
get matches() {
return matches;
},
removeListener(listener) {
listeners.delete(listener);
}
};
}

function loadAppearance(mediaQueryList) {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: jest.fn(() => mediaQueryList)
});

let Appearance;
jest.isolateModules(() => {
const module = require('..');
Appearance = module.default ?? module;
});
return Appearance;
}

const mediaQueryList = createMediaQueryList(false);
const Appearance = loadAppearance(mediaQueryList);

describe('Appearance', () => {
afterEach(() => {
if (Appearance.setColorScheme) {
Appearance.setColorScheme('auto');
}
mediaQueryList.emit(false);
});

test('overrides the system scheme and restores automatic behavior', () => {
expect(Appearance.getColorScheme()).toBe('light');

Appearance.setColorScheme('dark');
expect(Appearance.getColorScheme()).toBe('dark');

mediaQueryList.emit(true);
Appearance.setColorScheme('light');
expect(Appearance.getColorScheme()).toBe('light');

Appearance.setColorScheme('auto');
expect(Appearance.getColorScheme()).toBe('dark');

Appearance.setColorScheme('light');
Appearance.setColorScheme('unspecified');
expect(Appearance.getColorScheme()).toBe('dark');

Appearance.setColorScheme('light');
Appearance.setColorScheme(null);
expect(Appearance.getColorScheme()).toBe('dark');
});

test('notifies listeners when the effective scheme changes', () => {
const listener = jest.fn();
const subscription = Appearance.addChangeListener(listener);

expect(Appearance.getColorScheme()).toBe('light');
Appearance.setColorScheme('dark');
expect(listener).toHaveBeenLastCalledWith({ colorScheme: 'dark' });

mediaQueryList.emit(true);
mediaQueryList.emit(false);
expect(listener).toHaveBeenCalledTimes(1);

Appearance.setColorScheme('auto');
expect(listener).toHaveBeenLastCalledWith({ colorScheme: 'light' });

mediaQueryList.emit(true);
expect(listener).toHaveBeenLastCalledWith({ colorScheme: 'dark' });

Appearance.setColorScheme('dark');
expect(listener).toHaveBeenCalledTimes(3);

subscription.remove();
Appearance.setColorScheme('light');
expect(listener).toHaveBeenCalledTimes(3);
});

test('uses a stable listener snapshot during override changes', () => {
const secondListener = jest.fn();
let removeSecondListener = () => {};
const firstListener = jest.fn(() => {
removeSecondListener();
});
const firstSubscription = Appearance.addChangeListener(firstListener);
const secondSubscription = Appearance.addChangeListener(secondListener);
removeSecondListener = secondSubscription.remove;

Appearance.setColorScheme('dark');

expect(firstListener).toHaveBeenCalledTimes(1);
expect(secondListener).toHaveBeenCalledTimes(1);
firstSubscription.remove();
});
});
34 changes: 31 additions & 3 deletions packages/react-native-web/src/exports/Appearance/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type AppearancePreferences = {|
colorScheme: ColorSchemeName
|};

type ColorSchemeSetting = ColorSchemeName | 'auto' | 'unspecified' | null;
type AppearanceListener = (preferences: AppearancePreferences) => void;
type DOMAppearanceListener = (ev: MediaQueryListEvent) => any;

Expand All @@ -28,21 +29,47 @@ function getQuery(): MediaQueryList | null {
}

const query = getQuery();
let colorSchemeOverride: ColorSchemeName | null = null;
const appearanceListeners = new Set<AppearanceListener>();
const listenerMapping = new WeakMap<
AppearanceListener,
DOMAppearanceListener
>();

function getSystemColorScheme(): ColorSchemeName {
return query && query.matches ? 'dark' : 'light';
}

function notifyListeners(colorScheme: ColorSchemeName): void {
Array.from(appearanceListeners).forEach((listener) => {
listener({ colorScheme });
});
}

const Appearance = {
getColorScheme(): ColorSchemeName {
return query && query.matches ? 'dark' : 'light';
return colorSchemeOverride ?? getSystemColorScheme();
},

setColorScheme(colorScheme: ColorSchemeSetting): void {
const previousColorScheme = Appearance.getColorScheme();
colorSchemeOverride =
colorScheme === 'light' || colorScheme === 'dark' ? colorScheme : null;
const nextColorScheme = Appearance.getColorScheme();

if (nextColorScheme !== previousColorScheme) {
notifyListeners(nextColorScheme);
}
},

addChangeListener(listener: AppearanceListener): { remove: () => void } {
appearanceListeners.add(listener);
let mappedListener = listenerMapping.get(listener);
if (!mappedListener) {
mappedListener = ({ matches }: MediaQueryListEvent) => {
listener({ colorScheme: matches ? 'dark' : 'light' });
mappedListener = () => {
if (colorSchemeOverride == null) {
listener({ colorScheme: getSystemColorScheme() });
}
};
listenerMapping.set(listener, mappedListener);
}
Expand All @@ -55,6 +82,7 @@ const Appearance = {
if (query && mappedListener) {
query.removeListener(mappedListener);
}
appearanceListeners.delete(listener);
listenerMapping.delete(listener);
}

Expand Down