• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

zendeskgarden / react-components / #2641

20 Aug 2024 06:43PM UTC coverage: 96.022% (-0.4%) from 96.396%
#2641

push

web-flow
chore: merge v9 `next` to `main` (#1899)

3315 of 3674 branches covered (90.23%)

Branch coverage included in aggregate %.

2631 of 2675 new or added lines in 365 files covered. (98.36%)

3 existing lines in 1 file now uncovered.

10225 of 10427 relevant lines covered (98.06%)

235.58 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.93
/packages/theming/src/utils/getColor.ts
1
/**
2
 * Copyright Zendesk, Inc.
3
 *
4
 * Use of this source code is governed under the Apache License, Version 2.0
5
 * found at http://www.apache.org/licenses/LICENSE-2.0.
6
 */
7

8
import { getScale, parseToRgba } from 'color2k';
341✔
9
import { darken, getContrast, lighten, rgba } from 'polished';
341✔
10
import get from 'lodash.get';
341✔
11
import memoize from 'lodash.memoize';
341✔
12
import DEFAULT_THEME from '../elements/theme';
341✔
13
import { ColorParameters, Hue, IGardenTheme } from '../types';
14

15
const adjust = (color: string, expected: number, actual: number) => {
341✔
16
  if (expected !== actual) {
4✔
17
    // Adjust darkness/lightness if color is not the expected shade
18
    const amount = (Math.abs(expected - actual) / 100) * 0.05;
4✔
19

20
    return expected > actual ? darken(amount, color) : lighten(amount, color);
4✔
21
  }
22

NEW
23
  return color;
×
24
};
25

26
/* convert the optional shade + offset to a shade for the given scheme */
27
const toShade = (shade?: number | string, offset?: number, scheme?: 'dark' | 'light') => {
341✔
28
  let _shade;
29

30
  if (shade === undefined) {
55✔
31
    _shade = scheme === 'dark' ? 500 : 700;
24✔
32
  } else {
33
    _shade = parseInt(shade.toString(), 10);
31✔
34

35
    if (isNaN(_shade)) {
31✔
36
      throw new TypeError(`Error: unexpected '${typeof shade}' type for color shade "${shade}"`);
1✔
37
    }
38
  }
39

40
  return _shade + (offset || 0);
54✔
41
};
42

43
/* convert the given hue object to a hex color */
44
const toHex = (
341✔
45
  hue: Record<string | number, string>,
46
  shade?: number | string,
47
  offset?: number,
48
  scheme?: 'dark' | 'light'
49
) => {
50
  const _shade = toShade(shade, offset, scheme);
55✔
51
  let retVal = hue[_shade];
54✔
52

53
  if (!retVal) {
54✔
54
    const closestShade = Object.keys(hue)
4✔
55
      .map(hueShade => parseInt(hueShade, 10))
48✔
56
      .reduce((previous, current) => {
57
        // Find the closest available shade within the given hue
58
        return Math.abs(current - _shade) < Math.abs(previous - _shade) ? current : previous;
44✔
59
      });
60

61
    retVal = adjust(hue[closestShade], _shade, closestShade);
4✔
62
  }
63

64
  return retVal;
54✔
65
};
66

67
/* Validates color */
68
const isValidColor = (maybeColor: any) => {
341✔
69
  try {
12✔
70
    return !!parseToRgba(maybeColor);
12✔
71
  } catch {
72
    return false;
1✔
73
  }
74
};
75

76
/**
77
 *
78
 * Finds the index of the nearest element to a given target value in a sorted array using a binary search approach.
79
 */
80
function findNearestIndex(target: number, arr: number[], startIndex = 0) {
×
81
  if (typeof target !== 'number' || isNaN(target)) {
12!
NEW
82
    throw new Error('Target must be a number.');
×
83
  }
84
  if (!Array.isArray(arr)) {
12!
NEW
85
    throw new Error('Second argument must be an array.');
×
86
  }
87

88
  let left = startIndex;
12✔
89
  let right = arr.length - 1;
12✔
90

91
  if (target < arr[left]) return left;
12!
92
  if (target > arr[right]) return right;
12!
93

94
  while (left <= right) {
12✔
95
    const mid = Math.floor((left + right) / 2);
74✔
96
    if (arr[mid] === target) {
74✔
97
      return mid;
2✔
98
    } else if (arr[mid] < target) {
72✔
99
      left = mid + 1;
30✔
100
    } else {
101
      right = mid - 1;
42✔
102
    }
103
  }
104
  return arr[left] - target < target - arr[right] ? left : right;
10✔
105
}
106

107
const OFFSET_TO_TARGET_RATIO = {
341✔
108
  100: 1.08,
109
  200: 1.2,
110
  300: 1.35,
111
  400: 2,
112
  500: 2.8,
113
  600: 3.3,
114
  700: 5,
115
  800: 10,
116
  900: 13,
117
  1000: 16,
118
  1100: 17.5,
119
  1200: 19
120
};
121

122
/**
123
 * Generates a 12-step offset-based color scale.
124
 * Each key is an offset value and the corresponding value
125
 * is the color that best matches the target contrast ratio for that offset.
126
 */
127
const generateColorScale = memoize((color: string) => {
341✔
128
  /**
129
   * Based on empirical research, a scale of 200 colors
130
   * provided the best precision to size ratio.
131
   */
132
  const scaleSize = 200;
1✔
133
  const _scale = getScale('#FFF', color, '#000');
1✔
134
  const scale = (x: number) => _scale(x / scaleSize);
201✔
135

136
  const colors = [];
1✔
137
  const contrastRatios = [];
1✔
138

139
  for (let i = 0; i <= scaleSize; i++) {
1✔
140
    const _color = scale(i);
201✔
141
    colors.push(_color);
201✔
142
    contrastRatios.push(getContrast('#FFF', _color));
201✔
143
  }
144

145
  const palette: Record<string, string> = {};
1✔
146
  let startIndex = 0;
1✔
147

148
  for (const offset in OFFSET_TO_TARGET_RATIO) {
1✔
149
    if (Object.prototype.hasOwnProperty.call(OFFSET_TO_TARGET_RATIO, offset)) {
12✔
150
      const ratio = (OFFSET_TO_TARGET_RATIO as any)[offset];
12✔
151

152
      const nearestIndex = findNearestIndex(ratio, contrastRatios, startIndex);
12✔
153
      startIndex = nearestIndex + 1;
12✔
154

155
      palette[offset] = colors[nearestIndex];
12✔
156
    }
157
  }
158

159
  return palette;
1✔
160
});
161

162
/* convert the given hue + shade to a color */
163
const toColor = (
341✔
164
  colors: Omit<IGardenTheme['colors'], 'base' | 'variables'>,
165
  palette: IGardenTheme['palette'],
166
  opacity: IGardenTheme['opacity'],
167
  scheme: 'dark' | 'light',
168
  hue: string,
169
  shade?: number | string,
170
  offset?: number,
171
  transparency?: number
172
) => {
173
  let retVal;
174
  let _hue: Hue =
175
    colors[hue as keyof typeof colors] /* ex. `hue` = 'primaryHue' */ ||
66✔
176
    hue; /* ex. `hue` = '#fd5a1e' */
177

178
  // eslint-disable-next-line n/no-unsupported-features/es-builtins
179
  if (Object.hasOwn(palette, _hue)) {
66✔
180
    _hue = palette[_hue]; /* ex. `hue` = 'grey' */
58✔
181
  }
182

183
  if (typeof _hue === 'object') {
66✔
184
    retVal = toHex(_hue, shade, offset, scheme);
54✔
185
  } else if (_hue === 'transparent' || isValidColor(_hue)) {
12✔
186
    if (shade === undefined) {
11✔
187
      retVal = _hue;
10✔
188
    } else {
189
      _hue = generateColorScale(_hue);
1✔
190

191
      retVal = toHex(_hue, shade, offset, scheme);
1✔
192
    }
193
  }
194

195
  if (retVal && transparency) {
65✔
196
    const alpha = transparency > 1 ? opacity[transparency] : transparency;
12✔
197

198
    if (alpha === undefined) {
12✔
199
      throw new Error('Error: invalid `transparency` parameter');
1✔
200
    }
201

202
    retVal = rgba(retVal, alpha);
11✔
203
  }
204

205
  return retVal;
64✔
206
};
207

208
/* convert the given object + path to a string value */
209
const toProperty = (object: object, path: string) => {
341✔
210
  const retVal = get(object, path);
34✔
211

212
  if (typeof retVal === 'string') {
34✔
213
    return retVal;
32✔
214
  } else if (retVal === undefined) {
2✔
215
    throw new ReferenceError(`Error: color variable '${path}' is not defined`);
1✔
216
  } else {
217
    throw new TypeError(`Error: unexpected '${typeof retVal}' type for color variable "${path}"`);
1✔
218
  }
219
};
220

221
/* derive the property + transparency from the given rgba variable value */
222
const fromRgba = (value: string) => {
341✔
223
  let retVal;
224
  const regex =
225
    /rgba\s*\(\s*(?<property>[#\w.]+)\s*,\s*(?<alpha>[\w.]+)\s*\)/gu; /* ex. 'rgba(primaryHue.700, 600)' */
5✔
226
  const _rgba = regex.exec(value);
5✔
227

228
  if (_rgba && _rgba.groups) {
5✔
229
    const property = _rgba.groups.property;
4✔
230
    const transparency = parseFloat(_rgba.groups.alpha);
4✔
231

232
    retVal = { property, transparency };
4✔
233
  } else {
234
    throw new Error(`Error: invalid \`rgba\` value "${value}"`);
1✔
235
  }
236

237
  return retVal;
4✔
238
};
239

240
/* derive the hue + shade + transparency from the given variable */
241
const fromVariable = (
341✔
242
  variable: string,
243
  variables: IGardenTheme['colors']['variables']['dark' | 'light'],
244
  palette: IGardenTheme['palette']
245
) => {
246
  const retVal: { hue?: string; shade?: number; transparency?: number } = {};
29✔
247
  let property = toProperty(variables, variable);
29✔
248

249
  if (property.startsWith('rgba')) {
27✔
250
    const value = fromRgba(property);
5✔
251

252
    property = value.property;
4✔
253
    retVal.transparency = value.transparency;
4✔
254
  }
255

256
  const [key, value] = property.split(/\.(?<value>.*)/u);
26✔
257

258
  if (key === 'palette') {
26✔
259
    retVal.hue = toProperty(palette, value); /* ex. `variable` = 'palette.white' */
5✔
260
  } else {
261
    retVal.hue = key; /* ex. `variable` = '#fd5a1e' */
21✔
262

263
    if (value !== undefined) {
21✔
264
      retVal.shade = parseInt(value, 10); /* ex. `variable` = 'primaryHue.700' */
19✔
265
    }
266
  }
267

268
  return retVal;
26✔
269
};
270

271
/**
272
 * Get a color value from the theme. Variable lookup takes precedence, followed
273
 * by `dark` and `light` object values. If none of these are provided, `hue`,
274
 * `shade`, `offset`, and `transparency` are used as fallbacks to determine the
275
 * color.
276
 *
277
 * @param {Object} options.theme Provides values used to resolve the desired color
278
 * @param {string} [options.variable] A variable key (i.e. `'background.default'`) used to resolve a color value for the theme color base
279
 * @param {Object} [options.dark] An object with `hue`, `shade`, `offset`, and `transparency` values to be used in dark mode
280
 * @param {Object} [options.light] An object with `hue`, `shade`, `offset`, and `transparency` values to be used in light mode
281
 * @param {string} [options.hue] A `theme.palette` hue or one of the following `theme.colors` keys:
282
 *  - `'primaryHue'` = `theme.colors.primaryHue`
283
 *  - `'dangerHue'` = `theme.colors.dangerHue`
284
 *  - `'warningHue'` = `theme.colors.warningHue`
285
 *  - `'successHue'` = `theme.colors.successHue`
286
 *  - `'neutralHue'` = `theme.colors.neutralHue`
287
 *  - `'chromeHue'` = `theme.colors.chromeHue`
288
 * @param {number} [options.shade] A hue shade
289
 * @param {number} [options.offset] A positive or negative value to adjust the shade
290
 * @param {number} [options.transparency] A `theme.opacity` key or an alpha-channel value between 0 and 1
291
 */
292
export const getColor = memoize(
341✔
293
  ({ dark, hue, light, offset, shade, theme, transparency, variable }: ColorParameters) => {
294
    let retVal;
295

296
    // bulletproof object references for potential non-typed usage
297
    const palette =
298
      theme.palette && Object.keys(theme.palette).length > 0
70✔
299
        ? theme.palette
300
        : DEFAULT_THEME.palette;
301
    const { base, variables, ...colors } =
302
      theme.colors && Object.keys(theme.colors).length > 0 ? theme.colors : DEFAULT_THEME.colors;
70✔
303
    const scheme = base === 'dark' ? 'dark' : 'light';
70✔
304
    const mode = (scheme === 'dark' ? dark : light)!;
70✔
305
    let _hue = mode?.hue || hue;
70✔
306
    let _shade = mode?.shade === undefined ? shade : mode.shade;
70✔
307
    const _offset = mode?.offset === undefined ? offset : mode.offset;
70✔
308
    let _transparency = mode?.transparency === undefined ? transparency : mode.transparency;
70✔
309

310
    if (variable) {
70✔
311
      // variable lookup takes precedence
312
      const _variables = variables?.[scheme]
29✔
313
        ? variables[scheme]
314
        : DEFAULT_THEME.colors.variables[scheme];
315
      const value = fromVariable(variable, _variables, palette);
29✔
316

317
      _hue = value.hue;
26✔
318
      _shade = value.shade;
26✔
319
      _transparency = _transparency === undefined ? value.transparency : _transparency;
26✔
320
    }
321

322
    if (_hue) {
67✔
323
      const opacity =
324
        theme.opacity && Object.keys(theme.opacity).length > 0
66!
325
          ? theme.opacity
326
          : DEFAULT_THEME.opacity;
327

328
      retVal = toColor(colors, palette, opacity, scheme, _hue, _shade, _offset, _transparency);
66✔
329
    }
330

331
    if (retVal === undefined) {
65✔
332
      throw new Error('Error: invalid `getColor` parameters');
2✔
333
    }
334

335
    return retVal;
63✔
336
  },
337
  ({ dark, hue, light, offset, shade, theme, transparency, variable }) =>
338
    JSON.stringify({
147✔
339
      dark,
340
      hue,
341
      light,
342
      offset,
343
      shade,
344
      colors: theme.colors,
345
      palette: theme.palette,
346
      opacity: theme.opacity,
347
      transparency,
348
      variable
349
    })
350
);
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc