-
Notifications
You must be signed in to change notification settings - Fork 99
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: added a hook for generating a unique color
- Loading branch information
German Shteinardt
committed
May 3, 2024
1 parent
95fb990
commit 45aba44
Showing
17 changed files
with
417 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
18
src/hooks/useGeneratorColor/__stories__/GeneratorColor.scss
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
57 changes: 57 additions & 0 deletions
57
src/hooks/useGeneratorColor/__stories__/UseGeneratorColor.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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({}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
11
src/hooks/useGeneratorColor/__stories__/utils/randomString.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export {useGeneratorColor} from './useGeneratorColor'; | ||
export type {UseGeneratorColorProps} from './types'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}; |
Oops, something went wrong.