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