diff --git a/CHANGELOG.md b/CHANGELOG.md index f389d803..0372bc24 100644 --- a/CHANGELOG.md +++ b/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). +## [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 ### Added * *Nothing* diff --git a/src/tags/helpers/Tag.scss b/src/tags/helpers/Tag.scss index a3a5ecec..180ff07b 100644 --- a/src/tags/helpers/Tag.scss +++ b/src/tags/helpers/Tag.scss @@ -2,6 +2,10 @@ color: #fff; } +.tag--light-bg { + color: #000; +} + .tag:not(:last-child) { margin-right: 3px; } diff --git a/src/tags/helpers/Tag.tsx b/src/tags/helpers/Tag.tsx index 895caded..72dd31cd 100644 --- a/src/tags/helpers/Tag.tsx +++ b/src/tags/helpers/Tag.tsx @@ -1,4 +1,5 @@ import { FC, MouseEventHandler } from 'react'; +import classNames from 'classnames'; import ColorGenerator from '../../utils/services/ColorGenerator'; import './Tag.scss'; @@ -13,7 +14,7 @@ interface TagProps { const Tag: FC = ({ text, children, clearable, className = '', colorGenerator, onClick, onClose }) => ( diff --git a/src/utils/services/ColorGenerator.ts b/src/utils/services/ColorGenerator.ts index 46e988df..1fba86b2 100644 --- a/src/utils/services/ColorGenerator.ts +++ b/src/utils/services/ColorGenerator.ts @@ -2,16 +2,22 @@ import { rangeOf } from '../utils'; import LocalStorage from './LocalStorage'; const HEX_COLOR_LENGTH = 6; -const { floor, random } = Math; +const { floor, random, sqrt, round } = Math; const letters = '0123456789ABCDEF'; const buildRandomColor = () => `#${rangeOf(HEX_COLOR_LENGTH, () => letters[floor(random() * letters.length)]).join('')}`; 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 { private readonly colors: Record; + private readonly lights: Record; public constructor(private readonly storage: LocalStorage) { this.colors = this.storage.get>('colors') ?? {}; + this.lights = {}; } public readonly getColorForKey = (key: string) => { @@ -34,4 +40,16 @@ export default class ColorGenerator { 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]; + }; } diff --git a/test/tags/helpers/Tag.test.tsx b/test/tags/helpers/Tag.test.tsx new file mode 100644 index 00000000..44b68bba --- /dev/null +++ b/test/tags/helpers/Tag.test.tsx @@ -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('', () => { + const onClick = jest.fn(); + const onClose = jest.fn(); + const isColorLightForKey = jest.fn(() => false); + const getColorForKey = jest.fn(() => MAIN_COLOR); + const colorGenerator = Mock.of({ getColorForKey, isColorLightForKey }); + let wrapper: ShallowWrapper; + const createWrapper = (text: string, clearable?: boolean, children?: ReactNode) => { + wrapper = shallow( + + {children} + , + ); + + 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}`); + }); +}); diff --git a/test/utils/services/ColorGenerator.test.ts b/test/utils/services/ColorGenerator.test.ts index 990b268e..2836f8e6 100644 --- a/test/utils/services/ColorGenerator.test.ts +++ b/test/utils/services/ColorGenerator.test.ts @@ -1,6 +1,7 @@ import { Mock } from 'ts-mockery'; import ColorGenerator from '../../../src/utils/services/ColorGenerator'; import LocalStorage from '../../../src/utils/services/LocalStorage'; +import { MAIN_COLOR } from '../../../src/utils/theme'; describe('ColorGenerator', () => { let colorGenerator: ColorGenerator; @@ -44,4 +45,19 @@ describe('ColorGenerator', () => { expect(storageMock.set).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 + }); + }); });