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 @@ -19,6 +19,8 @@ eslintTester.run('../platform-colors', rule, {
valid: [
"const color = PlatformColor('labelColor');",
"const color = PlatformColor('controlAccentColor', 'controlColor');",
"const color = PlatformColor('labelColor', {fallback: '#FF0000'});",
"const color = PlatformColor('controlAccentColor', 'controlColor', {fallback: 'red'});",
"const color = DynamicColorIOS({light: 'black', dark: 'white'});",
"const color = DynamicColorIOS({light: PlatformColor('black'), dark: PlatformColor('white')});",
"const color = DynamicColorIOS({light: PlatformColor('black'), dark: PlatformColor('white'), highContrastLight: PlatformColor('black'), highContrastDark: PlatformColor('white')});",
Expand All @@ -32,6 +34,14 @@ eslintTester.run('../platform-colors', rule, {
code: "const labelColor = 'labelColor'; const color = PlatformColor(labelColor);",
errors: [{message: rule.meta.messages.platformColorArgTypes}],
},
{
code: "const raw = '#FF0000'; const color = PlatformColor('labelColor', {fallback: raw});",
errors: [{message: rule.meta.messages.platformColorArgTypes}],
},
{
code: "const color = PlatformColor({fallback: '#FF0000'}, 'labelColor');",
errors: [{message: rule.meta.messages.platformColorArgTypes}],
},
{
code: "const tuple = {light: 'black', dark: 'white'}; const color = DynamicColorIOS(tuple);",
errors: [{message: rule.meta.messages.dynamicColorIOSArg}],
Expand Down
22 changes: 21 additions & 1 deletion packages/eslint-plugin-react-native/platform-colors.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,34 @@ module.exports = {
CallExpression: function (node) {
if (node.callee.name === 'PlatformColor') {
const args = node.arguments;
// The optional trailing options object carries a lazy raw color
// fallback: PlatformColor('token', {fallback: '#RRGGBB'}). Its
// fallback value must itself be a literal so it stays statically
// analyzable.
const isFallbackObject = arg =>
arg.type === 'ObjectExpression' &&
arg.properties.length > 0 &&
arg.properties.every(
property =>
property.type === 'Property' &&
property.key.type === 'Identifier' &&
property.key.name === 'fallback' &&
property.value.type === 'Literal',
);
if (args.length === 0) {
context.report({
node,
messageId: 'platformColorArgsLength',
});
return;
}
if (!args.every(arg => arg.type === 'Literal')) {
if (
!args.every(
(arg, index) =>
arg.type === 'Literal' ||
(index === args.length - 1 && isFallbackObject(arg)),
)
) {
context.report({
node,
messageId: 'platformColorArgTypes',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,33 @@ import type {NativeColorValue} from './StyleSheet';
/** The actual type of the opaque NativeColorValue on Android platform */
type LocalNativeColorValue = {
resource_paths?: Array<string>,
fallback?: string,
};

export const PlatformColor = (...names: Array<string>): NativeColorValue => {
export const PlatformColor = (
...args: Array<string | {fallback: string}>
): NativeColorValue => {
const names: Array<string> = [];
let fallback: ?string;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (typeof arg === 'string') {
names.push(arg);
} else if (i === args.length - 1) {
// The {fallback} object is only honored as the trailing argument, matching
// the ESLint rule. It is a raw color string, intentionally not processed
// here; it crosses to native untouched and is only parsed on a token miss.
fallback = arg.fallback;
}
// A non-string before the final position is ignored (a lint error at authoring).
}
const color: LocalNativeColorValue = {resource_paths: names};
if (fallback != null) {
color.fallback = fallback;
}
/* $FlowExpectedError[incompatible-type]
* LocalNativeColorValue is the actual type of the opaque NativeColorValue on Android platform */
return {resource_paths: names} as LocalNativeColorValue;
return color as LocalNativeColorValue;
};

export const normalizeColorObject = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ import {OpaqueColorValue} from './StyleSheet';
*
* @see https://reactnative.dev/docs/platformcolor#example
*/
export function PlatformColor(...colors: string[]): OpaqueColorValue;
export function PlatformColor(
...colors: Array<string | {fallback: string}>
): OpaqueColorValue;
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {ColorValue, NativeColorValue} from './StyleSheet';
/** The actual type of the opaque NativeColorValue on iOS platform */
type LocalNativeColorValue = {
semantic?: Array<string>,
fallback?: string,
dynamic?: {
light: ?(ColorValue | ProcessedColorValue),
dark: ?(ColorValue | ProcessedColorValue),
Expand All @@ -22,9 +23,29 @@ type LocalNativeColorValue = {
},
};

export const PlatformColor = (...names: Array<string>): NativeColorValue => {
export const PlatformColor = (
...args: Array<string | {fallback: string}>
): NativeColorValue => {
const names: Array<string> = [];
let fallback: ?string;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (typeof arg === 'string') {
names.push(arg);
} else if (i === args.length - 1) {
// The {fallback} object is only honored as the trailing argument, matching
// the ESLint rule. It is a raw color string, intentionally not processed
// here; it crosses to native untouched and is only parsed on a token miss.
fallback = arg.fallback;
}
// A non-string before the final position is ignored (a lint error at authoring).
}
const color: LocalNativeColorValue = {semantic: names};
if (fallback != null) {
color.fallback = fallback;
}
// $FlowExpectedError[incompatible-type] LocalNativeColorValue is the iOS LocalNativeColorValue type
return {semantic: names} as LocalNativeColorValue;
return color as LocalNativeColorValue;
};

export type DynamicColorIOSTuplePrivate = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type {NativeColorValue} from './StyleSheet';
* @see https://reactnative.dev/docs/platformcolor#example
*/
declare export function PlatformColor(
...names: Array<string>
...names: Array<string | {fallback: string}>
): NativeColorValue;

declare export function normalizeColorObject(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ describe('processColor', () => {
const expectedColor = {dynamic: {light: 0xff000000, dark: 0xffffffff}};
expect(processedColor).toEqual(expectedColor);
});

it('should carry an unprocessed fallback on iOS PlatformColor colors', () => {
const color = PlatformColorIOS('systemRedColor', {fallback: '#ff0000'});
const processedColor = processColor(color);
const expectedColor = {
semantic: ['systemRedColor'],
fallback: '#ff0000',
};
expect(processedColor).toEqual(expectedColor);
});
}
});

Expand All @@ -120,6 +130,18 @@ describe('processColor', () => {
const expectedColor = {resource_paths: ['?attr/colorPrimary']};
expect(processedColor).toEqual(expectedColor);
});

it('should carry an unprocessed fallback on Android PlatformColor colors', () => {
const color = PlatformColorAndroid('?attr/colorPrimary', {
fallback: '#000000',
});
const processedColor = processColor(color);
const expectedColor = {
resource_paths: ['?attr/colorPrimary'],
fallback: '#000000',
};
expect(processedColor).toEqual(expectedColor);
});
}
});
});
72 changes: 72 additions & 0 deletions packages/react-native/React/Base/RCTConvert.mm
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,70 @@ + (RCTColorSpace)RCTColorSpaceFromString:(NSString *)colorSpace
return RCTGetDefaultColorSpace();
}

// Parses a raw hex color string (#RGB, #RGBA, #RRGGBB, #RRGGBBAA — alpha last, as
// in CSS) into a UIColor, or nil if it is not a supported hex color. Used only as
// a PlatformColor fallback when every semantic name misses. (On the New
// Architecture the fallback additionally supports rgb()/rgba()/named colors via
// the shared CSS color parser; this legacy path handles the common hex forms.)
static UIColor *RCTFallbackUIColorFromHexString(NSString *colorString)
{
if (![colorString isKindOfClass:[NSString class]]) {
return nil;
}
NSString *hex = [colorString stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
if (![hex hasPrefix:@"#"]) {
return nil;
}
hex = [hex substringFromIndex:1];
NSUInteger length = hex.length;
// Expand shorthand #RGB / #RGBA to #RRGGBB / #RRGGBBAA.
if (length == 3 || length == 4) {
NSMutableString *expanded = [NSMutableString stringWithCapacity:length * 2];
for (NSUInteger i = 0; i < length; i++) {
unichar c = [hex characterAtIndex:i];
[expanded appendFormat:@"%C%C", c, c];
}
hex = expanded;
length = hex.length;
}
if (length != 6 && length != 8) {
return nil;
}
NSCharacterSet *nonHex = [[NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdefABCDEF"] invertedSet];
if ([hex rangeOfCharacterFromSet:nonHex].location != NSNotFound) {
return nil;
}
unsigned int value = 0;
if (![[NSScanner scannerWithString:hex] scanHexInt:&value]) {
return nil;
}
CGFloat r;
CGFloat g;
CGFloat b;
CGFloat a;
if (length == 6) {
r = ((value >> 16) & 0xFF) / 255.0;
g = ((value >> 8) & 0xFF) / 255.0;
b = (value & 0xFF) / 255.0;
a = 1.0;
} else {
r = ((value >> 24) & 0xFF) / 255.0;
g = ((value >> 16) & 0xFF) / 255.0;
b = ((value >> 8) & 0xFF) / 255.0;
a = (value & 0xFF) / 255.0;
}
return [UIColor colorWithRed:r green:g blue:b alpha:a];
}

static UIColor *RCTFallbackUIColorFromColorDictionary(NSDictionary *dictionary)
{
id fallback = [dictionary objectForKey:@"fallback"];
if ([fallback isKindOfClass:[NSString class]]) {
return RCTFallbackUIColorFromHexString(fallback);
}
return nil;
}

+ (UIColor *)UIColor:(id)json
{
if (!json) {
Expand Down Expand Up @@ -983,6 +1047,10 @@ + (UIColor *)UIColor:(id)json
}
color = RCTColorFromSemanticColorName(semanticName);
if (color == nil) {
UIColor *fallbackColor = RCTFallbackUIColorFromColorDictionary(dictionary);
if (fallbackColor != nil) {
return fallbackColor;
}
RCTLogConvertError(
json,
[@"a UIColor. Expected one of the following values: " stringByAppendingString:RCTSemanticColorNames()]);
Expand All @@ -999,6 +1067,10 @@ + (UIColor *)UIColor:(id)json
return color;
}
}
UIColor *fallbackColor = RCTFallbackUIColorFromColorDictionary(dictionary);
if (fallbackColor != nil) {
return fallbackColor;
}
RCTLogConvertError(
json,
[@"a UIColor. None of the names in the array were one of the following values: "
Expand Down
Loading
Loading