mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +03:00
Merge pull request #558 from Roy-Orbison/tag-legibility
Make text of light tags legible
This commit is contained in:
commit
34a59db4cf
6 changed files with 143 additions and 2 deletions
17
CHANGELOG.md
17
CHANGELOG.md
|
@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
### Added
|
||||||
|
* [#558](https://github.com/shlinkio/shlink-web-client/pull/558) Added dark text for tags where the generated background is too light, improving its legibility.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
|
||||||
## [3.5.1] - 2022-01-08
|
## [3.5.1] - 2022-01-08
|
||||||
### Added
|
### Added
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
|
@ -2,6 +2,10 @@
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag--light-bg {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
.tag:not(:last-child) {
|
.tag:not(:last-child) {
|
||||||
margin-right: 3px;
|
margin-right: 3px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { FC, MouseEventHandler } from 'react';
|
import { FC, MouseEventHandler } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
import ColorGenerator from '../../utils/services/ColorGenerator';
|
import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||||
import './Tag.scss';
|
import './Tag.scss';
|
||||||
|
|
||||||
|
@ -13,7 +14,7 @@ interface TagProps {
|
||||||
|
|
||||||
const Tag: FC<TagProps> = ({ text, children, clearable, className = '', colorGenerator, onClick, onClose }) => (
|
const Tag: FC<TagProps> = ({ text, children, clearable, className = '', colorGenerator, onClick, onClose }) => (
|
||||||
<span
|
<span
|
||||||
className={`badge tag ${className}`}
|
className={classNames('badge tag', className, { 'tag--light-bg': colorGenerator.isColorLightForKey(text) })}
|
||||||
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
|
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
|
|
|
@ -2,16 +2,22 @@ import { rangeOf } from '../utils';
|
||||||
import LocalStorage from './LocalStorage';
|
import LocalStorage from './LocalStorage';
|
||||||
|
|
||||||
const HEX_COLOR_LENGTH = 6;
|
const HEX_COLOR_LENGTH = 6;
|
||||||
const { floor, random } = Math;
|
const { floor, random, sqrt, round } = Math;
|
||||||
const letters = '0123456789ABCDEF';
|
const letters = '0123456789ABCDEF';
|
||||||
const buildRandomColor = () => `#${rangeOf(HEX_COLOR_LENGTH, () => letters[floor(random() * letters.length)]).join('')}`;
|
const buildRandomColor = () => `#${rangeOf(HEX_COLOR_LENGTH, () => letters[floor(random() * letters.length)]).join('')}`;
|
||||||
const normalizeKey = (key: string) => key.toLowerCase().trim();
|
const normalizeKey = (key: string) => key.toLowerCase().trim();
|
||||||
|
const hexColorToRgbArray = (colorHex: string): number[] =>
|
||||||
|
(colorHex.match(/../g) ?? []).map((hex) => parseInt(hex, 16) || 0);
|
||||||
|
// HSP by Darel Rex Finley https://alienryderflex.com/hsp.html
|
||||||
|
const perceivedLightness = (r = 0, g = 0, b = 0) => round(sqrt(0.299 * r ** 2 + 0.587 * g ** 2 + 0.114 * b ** 2));
|
||||||
|
|
||||||
export default class ColorGenerator {
|
export default class ColorGenerator {
|
||||||
private readonly colors: Record<string, string>;
|
private readonly colors: Record<string, string>;
|
||||||
|
private readonly lights: Record<string, boolean>;
|
||||||
|
|
||||||
public constructor(private readonly storage: LocalStorage) {
|
public constructor(private readonly storage: LocalStorage) {
|
||||||
this.colors = this.storage.get<Record<string, string>>('colors') ?? {};
|
this.colors = this.storage.get<Record<string, string>>('colors') ?? {};
|
||||||
|
this.lights = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly getColorForKey = (key: string) => {
|
public readonly getColorForKey = (key: string) => {
|
||||||
|
@ -34,4 +40,16 @@ export default class ColorGenerator {
|
||||||
|
|
||||||
return color;
|
return color;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public readonly isColorLightForKey = (key: string): boolean => {
|
||||||
|
const colorHex = this.getColorForKey(key).substring(1);
|
||||||
|
|
||||||
|
if (!this.lights[colorHex]) {
|
||||||
|
const rgb = hexColorToRgbArray(colorHex);
|
||||||
|
|
||||||
|
this.lights[colorHex] = perceivedLightness(...rgb) >= 128;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.lights[colorHex];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
85
test/tags/helpers/Tag.test.tsx
Normal file
85
test/tags/helpers/Tag.test.tsx
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import ColorGenerator from '../../../src/utils/services/ColorGenerator';
|
||||||
|
import { MAIN_COLOR } from '../../../src/utils/theme';
|
||||||
|
import Tag from '../../../src/tags/helpers/Tag';
|
||||||
|
|
||||||
|
describe('<Tag />', () => {
|
||||||
|
const onClick = jest.fn();
|
||||||
|
const onClose = jest.fn();
|
||||||
|
const isColorLightForKey = jest.fn(() => false);
|
||||||
|
const getColorForKey = jest.fn(() => MAIN_COLOR);
|
||||||
|
const colorGenerator = Mock.of<ColorGenerator>({ getColorForKey, isColorLightForKey });
|
||||||
|
let wrapper: ShallowWrapper;
|
||||||
|
const createWrapper = (text: string, clearable?: boolean, children?: ReactNode) => {
|
||||||
|
wrapper = shallow(
|
||||||
|
<Tag text={text} clearable={clearable} colorGenerator={colorGenerator} onClick={onClick} onClose={onClose}>
|
||||||
|
{children}
|
||||||
|
</Tag>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(jest.clearAllMocks);
|
||||||
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ true ],
|
||||||
|
[ false ],
|
||||||
|
])('includes an extra class when the color is light', (isLight) => {
|
||||||
|
isColorLightForKey.mockReturnValue(isLight);
|
||||||
|
|
||||||
|
const wrapper = createWrapper('foo');
|
||||||
|
|
||||||
|
expect((wrapper.prop('className') as string).includes('tag--light-bg')).toEqual(isLight);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ MAIN_COLOR ],
|
||||||
|
[ '#8A661C' ],
|
||||||
|
[ '#F7BE05' ],
|
||||||
|
[ '#5A02D8' ],
|
||||||
|
[ '#202786' ],
|
||||||
|
])('includes generated color as backgroundColor', (generatedColor) => {
|
||||||
|
getColorForKey.mockReturnValue(generatedColor);
|
||||||
|
|
||||||
|
const wrapper = createWrapper('foo');
|
||||||
|
|
||||||
|
expect((wrapper.prop('style') as any).backgroundColor).toEqual(generatedColor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes expected callbacks when appropriate events are triggered', () => {
|
||||||
|
const wrapper = createWrapper('foo', true);
|
||||||
|
|
||||||
|
expect(onClick).not.toBeCalled();
|
||||||
|
expect(onClose).not.toBeCalled();
|
||||||
|
|
||||||
|
wrapper.simulate('click');
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
wrapper.find('.tag__close-selected-tag').simulate('click');
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ true, 1, 'auto' ],
|
||||||
|
[ false, 0, 'pointer' ],
|
||||||
|
[ undefined, 0, 'pointer' ],
|
||||||
|
])('includes a close component when the tag is clearable', (clearable, expectedCloseBtnAmount, expectedCursor) => {
|
||||||
|
const wrapper = createWrapper('foo', clearable);
|
||||||
|
|
||||||
|
expect(wrapper.find('.tag__close-selected-tag')).toHaveLength(expectedCloseBtnAmount);
|
||||||
|
expect((wrapper.prop('style') as any).cursor).toEqual(expectedCursor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ undefined, 'foo' ],
|
||||||
|
[ 'bar', 'bar' ],
|
||||||
|
])('falls back to text as children when no children are provided', (children, expectedChildren) => {
|
||||||
|
const wrapper = createWrapper('foo', false, children);
|
||||||
|
|
||||||
|
expect(wrapper.html()).toContain(`>${expectedChildren}</span>`);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,6 +1,7 @@
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import ColorGenerator from '../../../src/utils/services/ColorGenerator';
|
import ColorGenerator from '../../../src/utils/services/ColorGenerator';
|
||||||
import LocalStorage from '../../../src/utils/services/LocalStorage';
|
import LocalStorage from '../../../src/utils/services/LocalStorage';
|
||||||
|
import { MAIN_COLOR } from '../../../src/utils/theme';
|
||||||
|
|
||||||
describe('ColorGenerator', () => {
|
describe('ColorGenerator', () => {
|
||||||
let colorGenerator: ColorGenerator;
|
let colorGenerator: ColorGenerator;
|
||||||
|
@ -44,4 +45,19 @@ describe('ColorGenerator', () => {
|
||||||
expect(storageMock.set).toHaveBeenCalledTimes(1);
|
expect(storageMock.set).toHaveBeenCalledTimes(1);
|
||||||
expect(storageMock.get).toHaveBeenCalledTimes(1);
|
expect(storageMock.get).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isColorLightForKey', () => {
|
||||||
|
it.each([
|
||||||
|
[ MAIN_COLOR, true ],
|
||||||
|
[ '#8A661C', false ],
|
||||||
|
[ '#F7BE05', true ],
|
||||||
|
[ '#5A02D8', false ],
|
||||||
|
[ '#202786', false ],
|
||||||
|
])('returns that the color for a key is light based on the color assigned to that key', (color, isLight) => {
|
||||||
|
colorGenerator.setColorForKey('foo', color);
|
||||||
|
|
||||||
|
expect(isLight).toEqual(colorGenerator.isColorLightForKey('foo'));
|
||||||
|
expect(isLight).toEqual(colorGenerator.isColorLightForKey('foo')); // To cover when color is already calculated
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue