1import * as React from 'react'; 2import { 3 Appearance, 4 AccessibilityInfo, 5 AccessibilityChangeEventName, 6 StyleSheet, 7 ViewStyle, 8 ImageStyle, 9 TextStyle, 10 Dimensions, 11} from 'react-native'; 12 13import { ThemePreference, ThemePreferences } from './ThemeProvider'; 14 15type StyleType = ViewStyle | TextStyle | ImageStyle; 16 17type Options = { 18 base?: StyleType; 19 variants?: VariantMap<StyleType>; 20}; 21 22type VariantMap<T> = { [key: string]: { [key: string]: T } }; 23 24type Nested<Type> = { 25 [Property in keyof Type]?: keyof Type[Property]; 26}; 27 28type SelectorMap<Variants> = Partial<{ 29 [K in keyof Variants]?: { 30 [T in keyof Variants[K]]?: StyleType; 31 }; 32}>; 33 34type Selectors<Variants> = { 35 light?: SelectorMap<Variants>; 36 dark?: SelectorMap<Variants>; 37 boldText?: SelectorMap<Variants>; 38 grayScale?: SelectorMap<Variants>; 39 invertColors?: SelectorMap<Variants>; 40 reduceTransparency?: SelectorMap<Variants>; 41 screenReader?: SelectorMap<Variants>; 42 width?: { [key: string]: SelectorMap<Variants> }; 43 height?: { [key: string]: SelectorMap<Variants> }; 44}; 45 46const selectorStore = createSelectorStore(); 47 48export function create<T, O extends Options>( 49 component: React.ComponentType<T>, 50 config: O & { selectors?: Selectors<O['variants']>; props?: T } 51) { 52 const styleFn = getStylesFn(config); 53 config.selectors = config.selectors || {}; 54 55 const Component = React.forwardRef< 56 T, 57 React.PropsWithChildren<T> & Nested<typeof config['variants']> 58 >((props, ref) => { 59 const style = styleFn(props); 60 const selectorStyle = useSelectors(config.selectors, props); 61 62 return React.createElement(component, { 63 ...props, 64 ...config.props, 65 style: StyleSheet.flatten([ 66 style, 67 // @ts-ignore 68 props.style || {}, 69 selectorStyle, 70 ]), 71 ref, 72 }); 73 }); 74 75 return Component; 76} 77 78export function getStylesFn(options: Options) { 79 let styles: any = options.base || {}; 80 81 function handleVariantProps(props: any) { 82 options.variants = options.variants || {}; 83 styles = options.base; 84 85 for (const key in props) { 86 if (options.variants[key]) { 87 const value = props[key]; 88 89 const styleValue = options.variants[key][value]; 90 if (styleValue) { 91 styles = StyleSheet.flatten(StyleSheet.compose(styles, styleValue)); 92 } 93 } 94 } 95 96 return styles; 97 } 98 99 return handleVariantProps; 100} 101 102type SelectorStoreListener = (updatedKeys: string[], state: any) => void; 103 104function createSelectorStore() { 105 const activeSelectorMap: Record<string, boolean> = {}; 106 const dimensionMap: Record<string, number> = {}; 107 108 let listeners: SelectorStoreListener[] = []; 109 110 const currentPreference = ThemePreferences.getPreference(); 111 112 let currentColorScheme = Appearance.getColorScheme(); 113 114 if (currentPreference !== 'no-preference') { 115 currentColorScheme = currentPreference; 116 } 117 118 if (currentColorScheme != null) { 119 if (currentColorScheme === 'light') { 120 activeSelectorMap['light'] = true; 121 activeSelectorMap['dark'] = false; 122 } else if (currentColorScheme === 'dark') { 123 activeSelectorMap['light'] = false; 124 activeSelectorMap['dark'] = true; 125 } 126 127 notify(['light', 'dark']); 128 } 129 130 Appearance.addChangeListener(({ colorScheme }) => { 131 const currentPreference = ThemePreferences.getPreference(); 132 133 if (currentPreference === 'no-preference') { 134 if (colorScheme === 'light') { 135 activeSelectorMap['light'] = true; 136 activeSelectorMap['dark'] = false; 137 } else if (colorScheme === 'dark') { 138 activeSelectorMap['light'] = false; 139 activeSelectorMap['dark'] = true; 140 } else { 141 delete activeSelectorMap['light']; 142 delete activeSelectorMap['dark']; 143 } 144 145 notify(['light', 'dark']); 146 } 147 }); 148 149 ThemePreferences.addChangeListener((currentPreference: ThemePreference) => { 150 if (currentPreference === 'light') { 151 activeSelectorMap['light'] = true; 152 activeSelectorMap['dark'] = false; 153 } else if (currentPreference === 'dark') { 154 activeSelectorMap['light'] = false; 155 activeSelectorMap['dark'] = true; 156 } else { 157 const currentColorScheme = Appearance.getColorScheme(); 158 159 if (currentColorScheme != null) { 160 if (currentColorScheme === 'light') { 161 activeSelectorMap['light'] = true; 162 activeSelectorMap['dark'] = false; 163 } else if (currentColorScheme === 'dark') { 164 activeSelectorMap['light'] = false; 165 activeSelectorMap['dark'] = true; 166 } else { 167 delete activeSelectorMap['light']; 168 delete activeSelectorMap['dark']; 169 } 170 } 171 } 172 173 notify(['light', 'dark']); 174 }); 175 176 const a11yTraits: AccessibilityChangeEventName[] = [ 177 'boldTextChanged', 178 'grayscaleChanged', 179 'invertColorsChanged', 180 'reduceMotionChanged', 181 'reduceTransparencyChanged', 182 'screenReaderChanged', 183 ]; 184 185 a11yTraits.forEach((trait) => { 186 AccessibilityInfo.addEventListener(trait, (isActive) => { 187 activeSelectorMap[trait] = isActive; 188 notify([trait]); 189 }); 190 }); 191 192 async function getInitialValues() { 193 const [ 194 isBoldTextEnabled, 195 isGrayscaleEnabled, 196 isInvertColorsEnabled, 197 isReduceMotionEnabled, 198 isReduceTransparencyEnabled, 199 isScreenReaderEnabled, 200 ] = await Promise.all([ 201 AccessibilityInfo.isBoldTextEnabled(), 202 AccessibilityInfo.isGrayscaleEnabled(), 203 AccessibilityInfo.isInvertColorsEnabled(), 204 AccessibilityInfo.isReduceMotionEnabled(), 205 AccessibilityInfo.isReduceTransparencyEnabled(), 206 AccessibilityInfo.isScreenReaderEnabled(), 207 ]); 208 209 activeSelectorMap['boldText'] = isBoldTextEnabled; 210 activeSelectorMap['grayScale'] = isGrayscaleEnabled; 211 activeSelectorMap['invertColors'] = isInvertColorsEnabled; 212 activeSelectorMap['reduceMotion'] = isReduceMotionEnabled; 213 activeSelectorMap['reduceTransparency'] = isReduceTransparencyEnabled; 214 activeSelectorMap['screenReader'] = isScreenReaderEnabled; 215 216 notify(a11yTraits); 217 } 218 219 getInitialValues(); 220 221 const { width: initialWidth, height: initialHeight } = Dimensions.get('screen'); 222 223 dimensionMap['width'] = initialWidth; 224 dimensionMap['height'] = initialHeight; 225 226 Dimensions.addEventListener('change', ({ screen }) => { 227 dimensionMap['width'] = screen.width; 228 dimensionMap['height'] = screen.height; 229 230 notify(['width', 'height']); 231 }); 232 233 function subscribe(fn: SelectorStoreListener) { 234 listeners.push(fn); 235 236 notify([]); 237 238 return () => { 239 listeners = listeners.filter((l) => l !== fn); 240 }; 241 } 242 243 function getState() { 244 return { 245 ...activeSelectorMap, 246 ...dimensionMap, 247 }; 248 } 249 250 function notify(keys: string[]) { 251 const state = getState(); 252 listeners.forEach((listener) => listener(keys, state)); 253 } 254 255 return { 256 subscribe, 257 }; 258} 259 260function useSelectors(selectors: any, props: any) { 261 const isMounted = React.useRef(false); 262 263 React.useEffect(() => { 264 isMounted.current = true; 265 266 return () => { 267 isMounted.current = false; 268 }; 269 }, []); 270 271 const [activeVariants, setActiveVariants] = React.useState<any>({}); 272 273 React.useEffect(() => { 274 const unsubscribe = selectorStore.subscribe((keys, state) => { 275 const variants: any = {}; 276 277 Object.entries(state).forEach(([selectorKey, selectorValue]: any) => { 278 if (selectorValue !== false) { 279 if (selectorKey === 'width' || selectorKey === 'height') { 280 const queries = selectors[selectorKey]; 281 for (const mediaQuery in queries) { 282 const expression = `${selectorValue} ${mediaQuery}`; 283 try { 284 // eslint-disable-next-line 285 if (eval(expression)) { 286 mergeDeep(variants, queries[mediaQuery]); 287 } 288 } catch (error) { 289 console.warn( 290 `Did not pass in a valid query selector '${expression}' -> try a key with a valid expression like '> {number}'` 291 ); 292 } 293 } 294 } else { 295 mergeDeep(variants, selectors[selectorKey]); 296 } 297 } 298 }); 299 300 if (isMounted.current) { 301 setActiveVariants(variants); 302 } 303 }); 304 305 return () => unsubscribe(); 306 }, [selectors]); 307 308 const activeStyles = {}; 309 310 if (activeVariants['base']) { 311 mergeDeep(activeStyles, activeVariants['base']); 312 } 313 314 Object.entries(props).forEach(([variantKey, variantValue]: any) => { 315 if (activeVariants[variantKey] && activeVariants[variantKey][variantValue]) { 316 mergeDeep(activeStyles, activeVariants[variantKey][variantValue]); 317 } 318 }); 319 320 return activeStyles; 321} 322 323function mergeDeep(target: any, source: any) { 324 const isObject = (obj: any) => obj && typeof obj === 'object'; 325 326 if (!isObject(target) || !isObject(source)) { 327 return source; 328 } 329 330 Object.keys(source).forEach((key) => { 331 const targetValue = target[key]; 332 const sourceValue = source[key]; 333 334 if (Array.isArray(targetValue) && Array.isArray(sourceValue)) { 335 target[key] = targetValue.concat(sourceValue); 336 } else if (isObject(targetValue) && isObject(sourceValue)) { 337 target[key] = mergeDeep(Object.assign({}, targetValue), sourceValue); 338 } else { 339 target[key] = sourceValue; 340 } 341 }); 342 343 return target; 344} 345