Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added a hook for generating a unique color #1565

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 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
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 './useColorGenerator';
export * from './useFileInput';
export * from './useFocusWithin';
export * from './useForkRef';
Expand Down
26 changes: 26 additions & 0 deletions src/hooks/useColorGenerator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!--GITHUB_BLOCK-->

# useColorGenerator

<!--/GITHUB_BLOCK-->

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

The `useColorGenerator` 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.
amje marked this conversation as resolved.
Show resolved Hide resolved

## 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, an index is generated from the token passed, and the value from the color array at that index is returned | `string[]` | | | |
amje marked this conversation as resolved.
Show resolved Hide resolved

## Result

`useColorGenerator` returns an object with exactly two values:

1. color - unique color from a token.
2. textColor - text color (dark or light), ensurring higher contrast on generated color.
12 changes: 12 additions & 0 deletions src/hooks/useColorGenerator/__stories__/ColorGenerator.scss
amje marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@use '../../../components/variables.scss';

$block: '.#{variables.$ns}color-generator';
amje marked this conversation as resolved.
Show resolved Hide resolved

#{$block} {
&__color-items {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-block-start: 20px;
}
}
37 changes: 37 additions & 0 deletions src/hooks/useColorGenerator/__stories__/ColoredAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';

import {Avatar} from '../../../components/Avatar';
import type {AvatarProps} from '../../../components/Avatar';
import type {UseColorGeneratorProps} from '../types';
import {useColorGenerator} from '../useColorGenerator';

type ColoredAvatarProps = AvatarProps & {
withText: boolean;
mode: UseColorGeneratorProps['mode'];
token: UseColorGeneratorProps['token'];
};

export const ColoredAvatar = ({
mode,
theme,
token,
withText,
...avatarProps
}: ColoredAvatarProps) => {
const {color, textColor} = useColorGenerator({
token,
mode,
});

return (
<Avatar
{...avatarProps}
theme={theme}
text={withText ? token : undefined}
color={withText ? textColor : undefined}
title={color}
backgroundColor={color}
size="l"
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React from 'react';

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

import {Button} from '../../../components/Button';
import {block} from '../../../components/utils/cn';
import {randomString} from '../__tests__/utils/randomString';

import {ColoredAvatar} from './ColoredAvatar';

import './ColorGenerator.scss';

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

const meta: Meta = {
title: 'Hooks/useColorGenerator',
argTypes: {
mode: {
options: ['unsaturated', 'saturated', 'bright'],
control: {type: 'radio'},
},
withText: {
control: 'boolean',
},
},
};

export default meta;

type Story = StoryObj<typeof ColoredAvatar>;

const initValues = () => {
const result = Array.from({length: 10}, () => randomString(16));

return result;
};

const Template = (args: React.ComponentProps<typeof ColoredAvatar>) => {
const {mode, withText} = args;
const [tokens, setTokens] = React.useState<string[]>(initValues);

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

return (
<React.Fragment>
<Button title="generate color" onClick={onClick}>
Generate color
</Button>
<div className={b('color-items')}>
{tokens.map((token) => (
amje marked this conversation as resolved.
Show resolved Hide resolved
<ColoredAvatar key={token} token={token} mode={mode} withText={withText} />
))}
</div>
</React.Fragment>
);
};

export const Default: Story = {
render: (args) => {
return <Template {...args} />;
},
args: {
mode: 'unsaturated',
withText: false,
},
};
24 changes: 24 additions & 0 deletions src/hooks/useColorGenerator/__tests__/getHue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {getHue} from '../utils'; // Подставьте правильное имя файла

describe('getHue', () => {
amje marked this conversation as resolved.
Show resolved Hide resolved
test('returns values within the range [0, 360)', () => {
const MIN_HASH = -Math.pow(2, 31);
const MAX_HASH = Math.pow(2, 31);

for (let i = 0; i < 1000; i++) {
const hash = Math.random() * (MAX_HASH - MIN_HASH) + MIN_HASH;
const hue = getHue(hash);

expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
}

const maxHue = getHue(MAX_HASH);
const minHue = getHue(MIN_HASH);

expect(maxHue).toBeGreaterThanOrEqual(0);
expect(maxHue).toBeLessThan(360);
expect(minHue).toBeGreaterThanOrEqual(0);
expect(minHue).toBeLessThan(360);
});
});
23 changes: 23 additions & 0 deletions src/hooks/useColorGenerator/__tests__/randomIndex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {randomIndex} from '../utils';

import {randomString} from './utils/randomString';

describe('randomIndex', () => {
test('returns numbers within the range [0, max)', () => {
const MAX_VALUE = 500;

for (let i = 0; i <= 1000; i++) {
const token = randomString(16);
const index = randomIndex(token, MAX_VALUE);

expect(index).toBeGreaterThanOrEqual(0);
expect(index).toBeLessThan(MAX_VALUE);
}

const zeroIndex = randomIndex('test', 0);
expect(zeroIndex).toBe(0);

const oneIndex = randomIndex('test', 1);
expect(oneIndex).toBe(0);
});
});
11 changes: 11 additions & 0 deletions src/hooks/useColorGenerator/__tests__/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;
};
130 changes: 130 additions & 0 deletions src/hooks/useColorGenerator/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/* eslint-disable no-bitwise */
import {BLACK_COLOR, WHITE_COLOR, colorOptions} from './constants';
import type {ColorProps, ThemeColorSettings} from './types';
import {getHue, hashFnv32a, normalizeHash, randomIndex} from './utils';

export class ColorGenerator {
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 textColor() {
amje marked this conversation as resolved.
Show resolved Hide resolved
if (!this._hue || !this._saturation || !this._lightness) {
return WHITE_COLOR;
}

return this.getTextColor(this._hue, this._saturation, this._lightness);
}

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

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

// https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB
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];
};

// https://www.w3.org/TR/WCAG20/#relativeluminancedef
private getTextColor(
h: number,
s: number,
l: number,
lightColor = WHITE_COLOR,
darkColor = BLACK_COLOR,
) {
const rgb = this.hslToRgb(h, s, l);
const N = rgb.length;
const normalizedValues = Array(N);

for (let i = 0; i < N; i++) {
let c = rgb[i];
c /= 255.0;

if (c <= 0.04045) {
c /= 12.92;
} else {
c = Math.pow((c + 0.055) / 1.055, 2.4);
}

normalizedValues[i] = c;
}

const [r, g, b] = normalizedValues;
const L = 0.2126 * r + 0.7152 * g + 0.0722 * b;

return L > 0.179 ? darkColor : lightColor;
}

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;
}
}
34 changes: 34 additions & 0 deletions src/hooks/useColorGenerator/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';
2 changes: 2 additions & 0 deletions src/hooks/useColorGenerator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export {useColorGenerator} from './useColorGenerator';
export type {UseColorGeneratorProps} from './types';
amje marked this conversation as resolved.
Show resolved Hide resolved
Loading
Loading