Skip to content

Commit

Permalink
feat: added a hook for generating a unique color
Browse files Browse the repository at this point in the history
  • Loading branch information
German Shteinardt committed May 3, 2024
1 parent 95fb990 commit 45aba44
Show file tree
Hide file tree
Showing 17 changed files with 417 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './useActionHandlers';
export * from './useAsyncActionHandler';
export * from './useBodyScrollLock';
export * from './useControlledState';
export * from './useGeneratorColor';
export * from './useFileInput';
export * from './useFocusWithin';
export * from './useForkRef';
Expand Down
28 changes: 28 additions & 0 deletions src/hooks/useGeneratorColor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!--GITHUB_BLOCK-->

# useGeneratorColor

<!--/GITHUB_BLOCK-->

```tsx
import {useGeneratorColor} from '@gravity-ui/uikit';
```

The `useGeneratorColor` a hook that generates a unique (but consistent) background color based on some unique attribute (e.g., name, id, email). The background color remains unchanged with each update.

## Properties

| Name | Description | Type | Default |
| :-------- | :----------------------------------------------------------: | :-------: | :---------: | ------ | --------- |
| mode | Value to control color saturation | saturated | unsaturated | bright | saturated |
| token | Unique attribute of the entity (e.g., name, id, email) | string | |
| colorKeys | If an array of colors is passed, | string[] | undefined | |
| | an index is generated from the token passed, | | |
| | and the value from the color array at that index is returned | | |

## Result

`useGeneratorColor` returns an object with exactly two values:

1. color - unique color from a token.
2. oppositeColor - inverted color (black or white), ensuring higher text contrast compared to the current unique color, which is usually better for human perception.
30 changes: 30 additions & 0 deletions src/hooks/useGeneratorColor/__stories__/Color.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react';

import {Avatar} from '../../../components/Avatar';
import type {AvatarProps} from '../../../components/Avatar';
import type {UseGeneratorColorProps} from '../types';
import {useGeneratorColor} from '../useGeneratorColor';

type ColorProps = AvatarProps & {
withText: boolean;
mode: UseGeneratorColorProps['mode'];
token: UseGeneratorColorProps['token'];
};

export const Color = ({mode, theme, token, withText, ...avatarProps}: ColorProps) => {
const {color, oppositeColor} = useGeneratorColor({
token,
mode,
});

return (
<Avatar
{...avatarProps}
theme={theme}
text={withText ? token : undefined}
color={withText ? oppositeColor : undefined}
backgroundColor={color}
size="l"
/>
);
};
18 changes: 18 additions & 0 deletions src/hooks/useGeneratorColor/__stories__/GeneratorColor.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
@use '../../../components/variables.scss';

$block: '.#{variables.$ns}generator-color';

#{$block} {
&__actions {
display: flex;
gap: 4px;
margin-block-end: 20px;
align-items: center;
}

&__color-items {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';

import type {Meta, StoryFn} from '@storybook/react';

import {Button} from '../../../components/Button';
import {Checkbox} from '../../../components/Checkbox';
import {Select} from '../../../components/Select';
import {block} from '../../../components/utils/cn';
import type {UseGeneratorColorProps} from '../types';

import {Color} from './Color';
import {colorModes} from './constants';
import {randomString} from './utils/randomString';

import './GeneratorColor.scss';

const b = block('generator-color');

export default {title: 'Hooks/useGeneratorColor'} as Meta;

const DefaultTemplate: StoryFn = () => {
const [tokens, setTokens] = React.useState<string[]>([]);
const [mode, setMode] = React.useState<string[]>(['unsaturated']);
const [withText, setWithText] = React.useState(false);

const onClick = React.useCallback(() => {
const newToken = randomString(16);
setTokens((prev) => [newToken, ...prev]);
}, []);

return (
<React.Fragment>
<div className={b('actions')}>
<Button title="generate color" onClick={onClick}>
Generate color
</Button>
<Select title="select mode" value={mode} options={colorModes} onUpdate={setMode} />
<Checkbox checked={withText} onUpdate={setWithText}>
with text
</Checkbox>
</div>

<div className={b('color-items')}>
{tokens.map((token) => (
<Color
key={token}
token={token}
mode={mode[0] as UseGeneratorColorProps['mode']}
withText={withText}
/>
))}
</div>
</React.Fragment>
);
};

export const Default = DefaultTemplate.bind({});
5 changes: 5 additions & 0 deletions src/hooks/useGeneratorColor/__stories__/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const colorModes = [
{value: 'unsaturated', content: 'unsaturated'},
{value: 'saturated', content: 'saturated'},
{value: 'bright', content: 'bright'},
];
11 changes: 11 additions & 0 deletions src/hooks/useGeneratorColor/__stories__/utils/randomString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const MASK = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';

export const randomString = (length: number) => {
let result = '';

for (let index = length; index >= 0; index--) {
result += MASK[Math.round(Math.random() * (MASK.length - 1))];
}

return result;
};
2 changes: 2 additions & 0 deletions src/hooks/useGeneratorColor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export {useGeneratorColor} from './useGeneratorColor';
export type {UseGeneratorColorProps} from './types';
23 changes: 23 additions & 0 deletions src/hooks/useGeneratorColor/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export type ColorOptions = {
lightness: [number, number];
situration: [number, number];
};

export type ThemeColorSettings = {
saturated: ColorOptions;
unsaturated: ColorOptions;
bright: ColorOptions;
};

export type ColorProps = {
colorKeys?: string[];
mode?: 'saturated' | 'unsaturated' | 'bright';
token: string;
theme: string;
};

export type UseGeneratorColorProps = {
colorKeys?: string[];
mode?: 'saturated' | 'unsaturated' | 'bright';
token: string;
};
41 changes: 41 additions & 0 deletions src/hooks/useGeneratorColor/useGeneratorColor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/* eslint-disable valid-jsdoc */
import {useThemeType} from '../../components/theme/useThemeType';

import type {UseGeneratorColorProps} from './types';
import {colorGenerator} from './utils/color';

/**
* It is used to create a unique color from a token (string) and to obtain an inverted color (black or white),
* ensuring higher text contrast compared to the current color, which is usually better for human perception.
*
* Usage example:
```tsx
import React from 'react';
import {Avatar} from '@gravity-ui/uikit';
const Component = ({ token, text, ...avatarProps }) => {
const {color, oppositeColor} = useGeneratorColor({
token,
});
return (
<Avatar
{...avatarProps}
text={text}
color={text ? oppositeColor : undefined}
backgroundColor={color}
/>
);
};
```
*/
export function useGeneratorColor(props: UseGeneratorColorProps) {
const theme = useThemeType();

const options = colorGenerator({
...props,
theme,
});

return options;
}
119 changes: 119 additions & 0 deletions src/hooks/useGeneratorColor/utils/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/* eslint-disable no-bitwise */
import type {ColorProps, ThemeColorSettings} from '../types';

import {BLACK_COLOR, WHITE_COLOR, colorOptions} from './constants';
import {getHue} from './getHue';
import {hashFnv32a} from './hashFnv32a';
import {normalizeHash} from './normalizeHash';
import {randomIndex} from './randomIndex';

class Color {
private _colorKeys?: string[];
private _saturation?: number;
private _lightness?: number;
private _token: string;
private _hash: number;
private _mode: ColorProps['mode'];
private _hue: number | null;
private _saturationRange: [number, number];
private _lightnessRange: [number, number];
private _themeOptions: ThemeColorSettings;

constructor({token, colorKeys, theme, mode}: ColorProps) {
this._token = token;
this._mode = mode ?? 'unsaturated';
this._hash = this.getHash(token);
this._colorKeys = colorKeys;
this._themeOptions = colorOptions[theme];
this._lightnessRange = this._themeOptions[this._mode].lightness;
this._saturationRange = this._themeOptions[this._mode].situration;
this._hue = null;
}

get color() {
if (this._colorKeys && this._colorKeys.length > 0) {
const index = this.getColorKeysIndex();

return this._colorKeys[index];
}

return this.hslColor();
}

get oppositeColor() {
if (!this._hue || !this._saturation || !this._lightness) {
return WHITE_COLOR;
}

const luminance = this.getLuminance(this._hue, this._saturation, this._lightness);

return luminance > 0.7 ? BLACK_COLOR : WHITE_COLOR;
}

private getColorKeysIndex() {
if (!this._colorKeys || this._colorKeys.length === 0) {
return -1;
}

return randomIndex(this._token, this._colorKeys.length);
}

private hslToRgb = (h: number, s: number, l: number) => {
s /= 100;
l /= 100;

const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);

const f = (n: number) => {
return l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
};

const r = ~~(255 * f(0));
const g = ~~(255 * f(8));
const b = ~~(255 * f(4));

return [r, g, b] as [number, number, number];
};

private rgbToLuminance(r: number, g: number, b: number) {
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}

private getLuminance(h: number, s: number, l: number) {
const rgb = this.hslToRgb(h, s, l);
const luminance = this.rgbToLuminance(...rgb);

return luminance;
}

private hslColor() {
this._hue = getHue(this._hash);

this._saturation = normalizeHash(
this._hash,
this._saturationRange[0],
this._saturationRange[1],
);

this._lightness = normalizeHash(
this._hash,
this._lightnessRange[0],
this._lightnessRange[1],
);

const color = `hsl(${this._hue}deg ${this._saturation}% ${this._lightness}%)`;

return color;
}

private getHash(token: string) {
const hash = hashFnv32a(token, 0x73_6f_6d_65) ^ hashFnv32a(token, 0x64_6f_72_61);

return hash;
}
}

export const colorGenerator = (args: ColorProps) => {
return new Color(args);
};
34 changes: 34 additions & 0 deletions src/hooks/useGeneratorColor/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type {ColorOptions, ThemeColorSettings} from '../types';

const bright: ColorOptions = {
lightness: [45, 55],
situration: [45, 55],
};

export const colorOptions: Record<string, ThemeColorSettings> = {
dark: {
saturated: {
lightness: [40, 80],
situration: [15, 55],
},
unsaturated: {
lightness: [25, 35],
situration: [45, 55],
},
bright,
},
light: {
saturated: {
lightness: [40, 80],
situration: [15, 55],
},
unsaturated: {
lightness: [80, 90],
situration: [45, 55],
},
bright,
},
};

export const WHITE_COLOR = '#ffffff';
export const BLACK_COLOR = '#000000';
9 changes: 9 additions & 0 deletions src/hooks/useGeneratorColor/utils/getHue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/* eslint-disable no-bitwise */
import {mathFrac} from './mathFrac';

export const getHue = (hash: number) => {
const sin = Math.sin(hash); // 0.12345678910 or -0.12345678910
const fr = sin < 0 ? mathFrac(sin * 1000) : mathFrac(sin * 10_000); // 5678910

return ~~(Math.abs(fr) * 360);
};
Loading

0 comments on commit 45aba44

Please sign in to comment.