From 1e03eed6c09e94ec8f9ec9cb2ccf077764716b76 Mon Sep 17 00:00:00 2001 From: Roy-Orbison Date: Wed, 5 Jan 2022 17:36:01 +1030 Subject: [PATCH 1/4] Make text of light tags legible --- src/tags/helpers/Tag.scss | 4 ++++ src/tags/helpers/Tag.tsx | 2 +- src/utils/services/ColorGenerator.ts | 18 +++++++++++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/tags/helpers/Tag.scss b/src/tags/helpers/Tag.scss index a3a5ecec..5e0cfe7d 100644 --- a/src/tags/helpers/Tag.scss +++ b/src/tags/helpers/Tag.scss @@ -1,5 +1,9 @@ .tag { color: #fff; + + &.light-generated-bg { + color: #000; + } } .tag:not(:last-child) { diff --git a/src/tags/helpers/Tag.tsx b/src/tags/helpers/Tag.tsx index 895caded..e0dbc4ad 100644 --- a/src/tags/helpers/Tag.tsx +++ b/src/tags/helpers/Tag.tsx @@ -13,7 +13,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..bbcce8ec 100644 --- a/src/utils/services/ColorGenerator.ts +++ b/src/utils/services/ColorGenerator.ts @@ -2,16 +2,21 @@ 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: number = 0, g: number = 0, b: number = 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 +39,15 @@ export default class ColorGenerator { return color; }; + + public readonly isColorLightForKey = (key: string) => { + const colorHex = this.getColorForKey(key).substring(1); + + if (this.lights[colorHex] == undefined) { + const rgb = hexColorToRgbArray(colorHex); + this.lights[colorHex] = perceivedLightness(...rgb) >= 128; + } + + return this.lights[colorHex]; + }; } From b727a704a6b9393e6aaf004183b6f918194a864d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Jan 2022 12:06:28 +0100 Subject: [PATCH 2/4] Changed classes to use BEM, and fixed TS compilation errors --- src/tags/helpers/Tag.scss | 4 ++-- src/tags/helpers/Tag.tsx | 3 ++- src/utils/services/ColorGenerator.ts | 10 ++++++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/tags/helpers/Tag.scss b/src/tags/helpers/Tag.scss index 5e0cfe7d..180ff07b 100644 --- a/src/tags/helpers/Tag.scss +++ b/src/tags/helpers/Tag.scss @@ -1,9 +1,9 @@ .tag { color: #fff; +} - &.light-generated-bg { +.tag--light-bg { color: #000; - } } .tag:not(:last-child) { diff --git a/src/tags/helpers/Tag.tsx b/src/tags/helpers/Tag.tsx index e0dbc4ad..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 bbcce8ec..1fba86b2 100644 --- a/src/utils/services/ColorGenerator.ts +++ b/src/utils/services/ColorGenerator.ts @@ -6,9 +6,10 @@ 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); +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: number = 0, g: number = 0, b: number = 0) => round(sqrt(0.299 * r ** 2 + 0.587 * g ** 2 + 0.114 * b ** 2)); +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; @@ -40,11 +41,12 @@ export default class ColorGenerator { return color; }; - public readonly isColorLightForKey = (key: string) => { + public readonly isColorLightForKey = (key: string): boolean => { const colorHex = this.getColorForKey(key).substring(1); - if (this.lights[colorHex] == undefined) { + if (!this.lights[colorHex]) { const rgb = hexColorToRgbArray(colorHex); + this.lights[colorHex] = perceivedLightness(...rgb) >= 128; } From aca9218f9d1fae3ff86625f23cf6fadbd4f4fd85 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Jan 2022 12:16:31 +0100 Subject: [PATCH 3/4] Added test covering ColorGenerator.isColorLightForKey --- test/utils/services/ColorGenerator.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/utils/services/ColorGenerator.test.ts b/test/utils/services/ColorGenerator.test.ts index 990b268e..b2d435ce 100644 --- a/test/utils/services/ColorGenerator.test.ts +++ b/test/utils/services/ColorGenerator.test.ts @@ -44,4 +44,19 @@ describe('ColorGenerator', () => { expect(storageMock.set).toHaveBeenCalledTimes(1); expect(storageMock.get).toHaveBeenCalledTimes(1); }); + + describe('isColorLightForKey', () => { + it.each([ + [ '#4696e5', true ], // Shlink brand color + [ '#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 + }); + }); }); From 12f61d03be7884428ee8fde4686d48647d7be2f2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Jan 2022 12:41:32 +0100 Subject: [PATCH 4/4] Created Tag component test --- CHANGELOG.md | 17 +++++ test/tags/helpers/Tag.test.tsx | 85 ++++++++++++++++++++++ test/utils/services/ColorGenerator.test.ts | 3 +- 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 test/tags/helpers/Tag.test.tsx 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/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 b2d435ce..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; @@ -47,7 +48,7 @@ describe('ColorGenerator', () => { describe('isColorLightForKey', () => { it.each([ - [ '#4696e5', true ], // Shlink brand color + [ MAIN_COLOR, true ], [ '#8A661C', false ], [ '#F7BE05', true ], [ '#5A02D8', false ],