diff --git a/apis/theme/src/__tests__/palette-resolver.spec.js b/apis/theme/src/__tests__/palette-resolver.spec.js new file mode 100644 index 000000000..a9d017e68 --- /dev/null +++ b/apis/theme/src/__tests__/palette-resolver.spec.js @@ -0,0 +1,133 @@ +import paletteResolverFn from '../palette-resolver'; + +describe('palette-resolver', () => { + it('dataScales()', () => { + expect( + paletteResolverFn({ + scales: [ + { + propertyValue: 'p', + name: 'name', + translation: 't', + type: 'type', + scale: 'scale', + }, + ], + }).dataScales() + ).to.eql([ + { + key: 'p', + name: 'name', + translation: 't', + type: 'type', + colors: 'scale', + scheme: true, + }, + ]); + }); + + it('dataPalettes()', () => { + expect( + paletteResolverFn({ + palettes: { + data: [ + { + propertyValue: 'p', + name: 'name', + translation: 't', + type: 'type', + scale: 'scale', + }, + ], + }, + }).dataPalettes() + ).to.eql([ + { + key: 'p', + name: 'name', + translation: 't', + type: 'type', + colors: 'scale', + }, + ]); + }); + + it('uiPalettes()', () => { + expect( + paletteResolverFn({ + palettes: { + ui: [ + { + name: 'name', + translation: 't', + colors: 'colors', + }, + ], + }, + }).uiPalettes() + ).to.eql([ + { + key: 'ui', + name: 'name', + translation: 't', + type: 'row', + colors: 'colors', + }, + ]); + }); + + it('dataColors()', () => { + expect( + paletteResolverFn({ + dataColors: { + primaryColor: 'primary', + nullColor: 'null', + othersColor: 'others', + }, + }).dataColors() + ).to.eql({ + primary: 'primary', + nil: 'null', + others: 'others', + }); + }); + + describe('uiColor', () => { + let p; + let uiPalettes; + beforeEach(() => { + p = paletteResolverFn(); + uiPalettes = sinon.stub(p, 'uiPalettes'); + }); + afterEach(() => { + uiPalettes.restore(); + }); + + it('should return color when index < 0 or undefined', () => { + expect(p.uiColor({ color: 'red' })).to.equal('red'); + expect(p.uiColor({ color: 'red', index: -1 })).to.equal('red'); + expect(uiPalettes.callCount).to.equal(0); + }); + + it('should return color when ui palette is falsy', () => { + uiPalettes.returns([]); + expect(p.uiColor({ color: 'red', index: 0 })).to.equal('red'); + expect(uiPalettes.callCount).to.equal(1); + }); + + it('should return color when index is out of bounds', () => { + uiPalettes.returns([{ colors: ['a', 'b', 'c'] }]); + expect(p.uiColor({ color: 'red', index: 3 })).to.equal('red'); + + expect(uiPalettes.callCount).to.equal(1); + // should keep cached palette + p.uiColor({ color: 'red', index: 3 }); + expect(uiPalettes.callCount).to.equal(1); + }); + + it('should return index from palette when index is within bounds', () => { + uiPalettes.returns([{ colors: ['a', 'b', 'c'] }]); + expect(p.uiColor({ color: 'red', index: 1 })).to.equal('b'); + }); + }); +}); diff --git a/apis/theme/src/__tests__/set-theme.spec.js b/apis/theme/src/__tests__/set-theme.spec.js new file mode 100644 index 000000000..6a0335ba2 --- /dev/null +++ b/apis/theme/src/__tests__/set-theme.spec.js @@ -0,0 +1,82 @@ +describe('set theme', () => { + let sandbox; + let create; + let extend; + let base; + let light; + let dark; + let resolve; + before(() => { + sandbox = sinon.createSandbox(); + extend = sandbox.stub(); + resolve = sandbox.stub(); + base = { font: 'Arial' }; + light = { background: 'white' }; + dark = { background: 'black' }; + [{ default: create }] = aw.mock( + [ + [require.resolve('extend'), () => extend], + ['**/base.json', () => base], + ['**/light.json', () => light], + ['**/dark.json', () => dark], + ], + ['../set-theme.js'] + ); + }); + + afterEach(() => { + sandbox.reset(); + }); + + it('should extend from light theme by default', () => { + extend.returns({ + palettes: {}, + }); + create({}, resolve); + expect(extend.firstCall).to.have.been.calledWithExactly(true, {}, base, light); + }); + + it('should extend from dark theme when type is dark', () => { + extend.returns({ + palettes: {}, + }); + create( + { + type: 'dark', + }, + resolve + ); + expect(extend.firstCall).to.have.been.calledWithExactly(true, {}, base, dark); + }); + + it('should not extend scales and palette arrays', () => { + const root = { color: 'pink', palettes: {} }; + const merged = { palettes: { data: [], ui: [] }, scales: [] }; + extend.onFirstCall().returns(root); + extend.onSecondCall().returns(merged); + const t = { color: 'red' }; + const prevent = { scales: null, palettes: { data: null, ui: null } }; + create(t, resolve); + expect(extend.secondCall).to.have.been.calledWithExactly(true, {}, root, prevent, t); + expect(resolve).to.have.been.calledWithExactly(merged); + }); + + it('should add defaults if custom scales and palettes are not provided', () => { + const root = { color: 'pink', palettes: { data: 'data', ui: 'ui' }, scales: 'scales' }; + const merged = { palettes: {} }; + extend.onFirstCall().returns(root); + extend.onSecondCall().returns(merged); + const custom = { color: 'red' }; + create(custom, resolve); + expect(resolve).to.have.been.calledWithExactly({ + palettes: { data: 'data', ui: 'ui' }, + scales: 'scales', + }); + }); + + it('should return resolved theme', () => { + extend.onSecondCall().returns({ palettes: { data: [], ui: [] }, scales: [] }); + resolve.returns('resolved'); + expect(create({}, resolve)).to.equal('resolved'); + }); +}); diff --git a/apis/theme/src/__tests__/style-resolver.spec.js b/apis/theme/src/__tests__/style-resolver.spec.js new file mode 100644 index 000000000..94c7e9c64 --- /dev/null +++ b/apis/theme/src/__tests__/style-resolver.spec.js @@ -0,0 +1,65 @@ +describe('style-resolver', () => { + let sandbox; + let create; + let extend; + before(() => { + sandbox = sinon.createSandbox(); + extend = sandbox.stub(); + [{ default: create }] = aw.mock([[require.resolve('extend'), () => extend]], ['../style-resolver.js']); + }); + + afterEach(() => { + sandbox.reset(); + }); + + it('getStyle from root', () => { + const t = { + fontSize: '16px', + }; + const s = create('base.path', t); + + expect(s.getStyle('', 'fontSize')).to.equal('16px'); + }); + + it('getStyle from object', () => { + const t = { + object: { + bar: { + legend: { + title: { + fontSize: '13px', + }, + }, + }, + }, + fontSize: '16px', + }; + const s = create('object.bar', t); + + expect(s.getStyle('legend.title', 'fontSize')).to.equal('13px'); + }); + + it('resolveRawTheme', () => { + const variables = { + '@text': 'pink', + '@size': 'mini', + }; + const raw = { + chart: { + bg: 'red', + color: '@text', + }, + responsive: '@size', + _variables: variables, + }; + extend.returns(raw); + expect(create.resolveRawTheme(raw)).to.eql({ + chart: { + bg: 'red', + color: 'pink', + }, + responsive: 'mini', + _variables: variables, + }); + }); +}); diff --git a/apis/theme/src/__tests__/theme.spec.js b/apis/theme/src/__tests__/theme.spec.js new file mode 100644 index 000000000..6c3425a75 --- /dev/null +++ b/apis/theme/src/__tests__/theme.spec.js @@ -0,0 +1,136 @@ +describe('theme', () => { + let sandbox; + let create; + let setTheme; + let paletterResolverFn; + let styleResolverFn; + let contrasterFn; + let luminanceFn; + let emitter; + before(() => { + sandbox = sinon.createSandbox(); + setTheme = sandbox.stub(); + paletterResolverFn = sandbox.stub(); + styleResolverFn = sandbox.stub(); + styleResolverFn.resolveRawTheme = 'raw'; + contrasterFn = sandbox.stub(); + luminanceFn = sandbox.stub(); + emitter = { + prototype: { + emit: sandbox.stub(), + }, + init: sandbox.stub(), + }; + [{ default: create }] = aw.mock( + [ + [require.resolve('node-event-emitter'), () => emitter], + ['**/set-theme.js', () => setTheme], + ['**/palette-resolver.js', () => paletterResolverFn], + ['**/style-resolver.js', () => styleResolverFn], + ['**/contraster.js', () => contrasterFn], + ['**/luminance.js', () => luminanceFn], + ], + ['../index.js'] + ); + }); + + afterEach(() => { + sandbox.reset(); + }); + + describe('initiate', () => { + beforeEach(() => { + setTheme.withArgs({}, 'raw').returns('resolvedJSON'); + const getStyle = sandbox.stub(); + getStyle.returns('red'); + styleResolverFn.withArgs('', 'resolvedJSON').returns({ + getStyle, + }); + }); + + it('should create paletteResolver', () => { + create(); + expect(paletterResolverFn).to.have.been.calledWithExactly('resolvedJSON'); + }); + + it('should create contraster for dark text color', () => { + luminanceFn.returns(0.19); + create(); + expect(contrasterFn).to.have.been.calledWithExactly(['red', '#ffffff']); + }); + + it('should create contraster for light text color', () => { + create(); + expect(contrasterFn).to.have.been.calledWithExactly(['red', '#333333']); + }); + + it("should emit 'changed' event", () => { + const t = create(); + expect(t.externalAPI.emit).to.have.been.calledWithExactly('changed'); + }); + }); + + describe('api', () => { + let t; + let resolved; + beforeEach(() => { + resolved = 'resolved'; + setTheme.returns(resolved); + paletterResolverFn.returns({ + dataScales: () => 'p scales', + dataPalettes: () => 'p data palettes', + uiPalettes: () => `p ui palettes`, + dataColors: () => 'p data colors', + uiColor: a => `p ui ${a}`, + }); + + styleResolverFn.withArgs('', 'resolved').returns({ + getStyle: () => '#eeeeee', + }); + + contrasterFn.returns({ getBestContrastColor: c => `contrast ${c}` }); + + t = create().externalAPI; + }); + + it('getDataColorScales()', () => { + expect(t.getDataColorScales()).to.equal('p scales'); + }); + + it('getDataColorPalettes()', () => { + expect(t.getDataColorPalettes()).to.equal('p data palettes'); + }); + + it('getDataColorPickerPalettes()', () => { + expect(t.getDataColorPickerPalettes()).to.equal('p ui palettes'); + }); + + it('getDataColorSpecials()', () => { + expect(t.getDataColorSpecials()).to.equal('p data colors'); + }); + + it('getColorPickerColor()', () => { + expect(t.getColorPickerColor('color')).to.equal('p ui color'); + }); + + it('getContrastingColorTo()', () => { + expect(t.getContrastingColorTo('color')).to.equal('contrast color'); + }); + + it('getStyle()', () => { + const getStyle = sandbox.stub(); + getStyle.returns('style'); + styleResolverFn.withArgs('base', 'resolved').returns({ + getStyle, + }); + expect(t.getStyle('base', 'path', 'attribute')).to.equal('style'); + + // calling additional getStyle with same params should use cached style resolver + expect(styleResolverFn.callCount).to.equal(2); + t.getStyle('base', 'path', 'attribute'); + t.getStyle('base', 'path', 'attribute'); + t.getStyle('base', 'path', 'attribute'); + expect(styleResolverFn.callCount).to.equal(2); + }); + }); +}); diff --git a/apis/theme/src/contraster/__tests__/contrast.spec.js b/apis/theme/src/contraster/__tests__/contrast.spec.js new file mode 100644 index 000000000..8e3c7792b --- /dev/null +++ b/apis/theme/src/contraster/__tests__/contrast.spec.js @@ -0,0 +1,22 @@ +import contrast from '../contrast'; + +describe('contrast', () => { + it('should be 1 for same luminance', () => { + expect(contrast(0, 0)).to.equal(1); + }); + + it('should be 21 when delta in luminance is 1', () => { + expect(contrast(0, 1)).to.equal(21); + }); + + it('should return same value even when luminances are in wrong order', () => { + const v = 2.6; + const lums = [0.2, 0.6]; + expect(contrast(...lums)).to.equal(v); + expect(contrast(...lums.reverse())).to.equal(v); + }); + + it('should be 1.72727 when luminances are [0.9, 0.5]', () => { + expect(contrast(0.9, 0.5)).to.equal(1.72727); + }); +}); diff --git a/apis/theme/src/contraster/__tests__/contraster.spec.js b/apis/theme/src/contraster/__tests__/contraster.spec.js new file mode 100644 index 000000000..789d64906 --- /dev/null +++ b/apis/theme/src/contraster/__tests__/contraster.spec.js @@ -0,0 +1,55 @@ +describe('contraster', () => { + let sandbox; + let create; + let luminance; + let contrast; + before(() => { + sandbox = sinon.createSandbox(); + luminance = sandbox.stub(); + contrast = sandbox.stub(); + [{ default: create }] = aw.mock( + [ + ['**/luminance.js', () => luminance], + ['**/contrast.js', () => contrast], + ], + ['../contraster.js'] + ); + }); + + afterEach(() => { + sandbox.reset(); + }); + + beforeEach(() => { + luminance.withArgs('#ffffff').returns(1); + luminance.withArgs('#333333').returns(0.1); + }); + + it('should return #ffffff by default when input is dark', () => { + luminance.withArgs('#111111').returns(0.05); + contrast.withArgs(0.05, 0.1).returns(5); + contrast.withArgs(0.05, 1).returns(20); + expect(create().getBestContrastColor('#111111')).to.equal('#ffffff'); + }); + + it('should return #333333 by default when input is light', () => { + luminance.withArgs('#afa').returns(0.8); + contrast.withArgs(0.8, 0.1).returns(10); + contrast.withArgs(0.8, 1).returns(2); + expect(create().getBestContrastColor('#afa')).to.equal('#333333'); + }); + + it('should return cached value', () => { + luminance.withArgs('#afa').returns(0.8); + contrast.withArgs(0.8, 0.1).returns(10); + contrast.withArgs(0.8, 1).returns(2); + + const c = create(); + c.getBestContrastColor('#afa'); + expect(luminance.callCount).to.equal(3); + + c.getBestContrastColor('#afa'); + c.getBestContrastColor('#afa'); + expect(luminance.callCount).to.equal(3); + }); +}); diff --git a/apis/theme/src/contraster/__tests__/luminance.spec.js b/apis/theme/src/contraster/__tests__/luminance.spec.js new file mode 100644 index 000000000..2e0cb3c85 --- /dev/null +++ b/apis/theme/src/contraster/__tests__/luminance.spec.js @@ -0,0 +1,30 @@ +describe('luminance', () => { + let sandbox; + let luminance; + let d3Color; + before(() => { + sandbox = sinon.createSandbox(); + d3Color = sandbox.stub(); + luminance = sandbox.stub(); + [{ default: luminance }] = aw.mock([['**/d3-color.js', () => ({ color: d3Color })]], ['../luminance.js']); + }); + + afterEach(() => { + sandbox.reset(); + }); + + it('for #ffffff should be 1', () => { + d3Color.withArgs('#ffffff').returns({ rgb: () => ({ r: 255, g: 255, b: 255 }) }); + expect(luminance('#ffffff')).to.equal(1); + }); + + it('for #000000 should be 0', () => { + d3Color.withArgs('#000000').returns({ rgb: () => ({ r: 0, g: 0, b: 0 }) }); + expect(luminance('#000000')).to.equal(0); + }); + + it('for #ff6633 should be 0.31002', () => { + d3Color.withArgs('#ff6633').returns({ rgb: () => ({ r: 255, g: 102, b: 51 }) }); + expect(luminance('#ff6633')).to.equal(0.31002); + }); +}); diff --git a/apis/theme/src/contraster/contrast.js b/apis/theme/src/contraster/contrast.js new file mode 100644 index 000000000..2d2ca8397 --- /dev/null +++ b/apis/theme/src/contraster/contrast.js @@ -0,0 +1,4 @@ +// https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html#contrast-ratiodef +export default function contrast(L1, L2) { + return +((Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05)).toFixed(5); +} diff --git a/apis/theme/src/contraster.js b/apis/theme/src/contraster/contraster.js similarity index 50% rename from apis/theme/src/contraster.js rename to apis/theme/src/contraster/contraster.js index 025c2283b..cfe5a9266 100644 --- a/apis/theme/src/contraster.js +++ b/apis/theme/src/contraster/contraster.js @@ -1,20 +1,6 @@ /* eslint no-cond-assign: 0 */ -import { color } from 'd3-color'; - -export function luminance(colStr) { - const c = color(colStr).rgb(); - const { r, g, b } = c; - - // https://www.w3.org/TR/WCAG20/#relativeluminancedef - const [sR, sG, sB] = [r, g, b].map(v => v / 255); - const [R, G, B] = [sR, sG, sB].map(v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); - - return +(0.2126 * R + 0.7152 * G + 0.0722 * B).toFixed(5); -} - -function contrast(c1, c2) { - return +((Math.max(c1, c2) + 0.05) / (Math.min(c1, c2) + 0.05)).toFixed(5); -} +import luminance from './luminance'; +import contrast from './contrast'; const MAX_SIZE = 1000; @@ -33,7 +19,6 @@ export default function colorFn(colors = ['#333333', '#ffffff']) { } const L = luminance(colorString); - // https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html#contrast-ratiodef const contrasts = luminances.map(lum => contrast(L, lum)); const c = colors[contrasts.indexOf(Math.max(...contrasts))]; diff --git a/apis/theme/src/contraster/luminance.js b/apis/theme/src/contraster/luminance.js new file mode 100644 index 000000000..c3ba083d3 --- /dev/null +++ b/apis/theme/src/contraster/luminance.js @@ -0,0 +1,12 @@ +import { color } from 'd3-color'; + +export default function luminance(colStr) { + const c = color(colStr).rgb(); + const { r, g, b } = c; + + // https://www.w3.org/TR/WCAG20/#relativeluminancedef + const [sR, sG, sB] = [r, g, b].map(v => v / 255); + const [R, G, B] = [sR, sG, sB].map(v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); + + return +(0.2126 * R + 0.7152 * G + 0.0722 * B).toFixed(5); +} diff --git a/apis/theme/src/index.js b/apis/theme/src/index.js index a4c977234..db21d0fdf 100644 --- a/apis/theme/src/index.js +++ b/apis/theme/src/index.js @@ -1,16 +1,12 @@ -import extend from 'extend'; import EventEmitter from 'node-event-emitter'; +import setTheme from './set-theme'; +import paletteResolverFn from './palette-resolver'; import styleResolverFn from './style-resolver'; -import paletteResolverFn from './paletter-resolver'; -import contrasterFn, { luminance } from './contraster'; - -import baseRawJSON from './themes/base.json'; -import lightRawJSON from './themes/light.json'; -import darkRawJSON from './themes/dark.json'; +import contrasterFn from './contraster/contraster'; +import luminance from './contraster/luminance'; export default function theme() { - let rawThemeJSON; let resolvedThemeJSON; let styleResolverInstanceCache = {}; @@ -38,8 +34,8 @@ export default function theme() { /** * @returns {colorPickerPalette[]} */ - getDataColorPickerPalettes(...a) { - return paletteResolver.uiPalettes(...a); + getDataColorPickerPalettes() { + return paletteResolver.uiPalettes(); }, /** * @returns {dataColorSpecials} @@ -104,22 +100,9 @@ export default function theme() { * @param {object} t Raw JSON theme */ setTheme(t) { - const colorRawJSON = t.type === 'dark' ? darkRawJSON : lightRawJSON; - const root = extend(true, {}, baseRawJSON, colorRawJSON); - // avoid merging known array objects as it could cause issues if they are of different types (pyramid vs class) or length - rawThemeJSON = extend(true, {}, root, { scales: null, palettes: { data: null, ui: null } }, t); - if (!rawThemeJSON.palettes.data) { - rawThemeJSON.palettes.data = root.palettes.data; - } - if (!rawThemeJSON.palettes.ui) { - rawThemeJSON.palettes.ui = root.palettes.ui; - } - if (!rawThemeJSON.scales) { - rawThemeJSON.scales = root.scales; - } + resolvedThemeJSON = setTheme(t, styleResolverFn.resolveRawTheme); styleResolverInstanceCache = {}; - resolvedThemeJSON = styleResolverFn.resolveRawTheme(rawThemeJSON); paletteResolver = paletteResolverFn(resolvedThemeJSON); // try to determine if the theme color is light or dark diff --git a/apis/theme/src/paletter-resolver.js b/apis/theme/src/palette-resolver.js similarity index 85% rename from apis/theme/src/paletter-resolver.js rename to apis/theme/src/palette-resolver.js index 669130d5f..b78897b70 100644 --- a/apis/theme/src/paletter-resolver.js +++ b/apis/theme/src/palette-resolver.js @@ -22,20 +22,6 @@ export default function theme(resolvedTheme) { let uiPalette; return { - palettes(type, key = '') { - const pals = []; - if (type === 'qualitative') { - pals.push(...this.dataPalettes()); - } else if (type === 'scale') { - pals.push(...this.dataScales()); - } else { - pals.push(...this.dataPalettes(), ...this.dataScales()); - } - if (key) { - return pals.filter(p => p.key === key); - } - return pals; - }, dataScales() { const pals = []; resolvedTheme.scales.forEach(s => { diff --git a/apis/theme/src/set-theme.js b/apis/theme/src/set-theme.js new file mode 100644 index 000000000..fc0240424 --- /dev/null +++ b/apis/theme/src/set-theme.js @@ -0,0 +1,25 @@ +import extend from 'extend'; + +import baseRawJSON from './themes/base.json'; +import lightRawJSON from './themes/light.json'; +import darkRawJSON from './themes/dark.json'; + +export default function setTheme(t, resolve) { + const colorRawJSON = t.type === 'dark' ? darkRawJSON : lightRawJSON; + const root = extend(true, {}, baseRawJSON, colorRawJSON); + // avoid merging known array objects as it could cause issues if they are of different types (pyramid vs class) or length + const rawThemeJSON = extend(true, {}, root, { scales: null, palettes: { data: null, ui: null } }, t); + if (!rawThemeJSON.palettes.data) { + rawThemeJSON.palettes.data = root.palettes.data; + } + if (!rawThemeJSON.palettes.ui) { + rawThemeJSON.palettes.ui = root.palettes.ui; + } + if (!rawThemeJSON.scales) { + rawThemeJSON.scales = root.scales; + } + + const resolvedThemeJSON = resolve(rawThemeJSON); + + return resolvedThemeJSON; +} diff --git a/aw.config.js b/aw.config.js index f3ca09f86..ce6a2e182 100644 --- a/aw.config.js +++ b/aw.config.js @@ -4,6 +4,6 @@ module.exports = { }, mocks: [], nyc: { - exclude: ['**/commands/**'], + exclude: ['**/commands/**', '**/__stories__/**'], }, };