feat(theme): add theme API (#137)

This commit is contained in:
Miralem Drek
2019-10-18 09:46:10 +02:00
committed by GitHub
parent 20bb7131ee
commit e2aa2eb658
15 changed files with 535 additions and 5 deletions

View File

@@ -36,6 +36,7 @@
"@material-ui/icons": "^4.5.1",
"@material-ui/styles": "^4.5.0",
"@nebula.js/supernova": "0.1.0-alpha.21",
"@nebula.js/theme": "0.1.0-alpha.21",
"@nebula.js/ui": "0.1.0-alpha.21",
"node-event-emitter": "^0.0.1",
"react": "^16.10.2",

View File

@@ -19,6 +19,7 @@ describe('viz', () => {
const { api: foo } = create({
model: 'a',
config: {},
context: {},
});
api = foo;
});
@@ -40,7 +41,7 @@ describe('viz', () => {
it('should not initiate mount when layout and sn are not defined', async () => {
const boot = sinon.spy();
const [{ default: create }] = doMock({ boot });
const { api, setObjectProps } = create({});
const { api, setObjectProps } = create({ context: {} });
api.mount('element');
@@ -53,7 +54,7 @@ describe('viz', () => {
it('should initiate React mount when layout and supernova are valid', async () => {
const boot = sinon.spy();
const [{ default: create }] = doMock({ boot });
const { api, setObjectProps } = create({});
const { api, setObjectProps } = create({ context: {} });
api.mount('element');
@@ -73,7 +74,7 @@ describe('viz', () => {
return {};
});
const [{ default: create }] = doMock({ boot });
const { api, setObjectProps } = create({});
const { api, setObjectProps } = create({ context: {} });
let mounted = false;
@@ -102,6 +103,7 @@ describe('viz', () => {
};
const { api } = create({
model,
context: {},
});
await api.setTemporaryProperties('new');
expect(getter).to.have.been.calledWithExactly(model, 'effectiveProperties');
@@ -118,6 +120,7 @@ describe('viz', () => {
};
const { api } = create({
model,
context: {},
});
await api.setTemporaryProperties('new');
expect(getter).to.have.been.calledWithExactly(model, 'effectiveProperties');

View File

@@ -136,8 +136,16 @@ class Supernova extends React.Component {
resizeObserver.observe(this.element);
}
const onThemeChanged = () => {
this.setState({});
};
this.props.snContext.theme.on('changed', onThemeChanged);
this.theme = this.props.snContext.theme;
this.onUnmount = () => {
this.onUnmount = null;
this.props.snContext.theme.removeListener('changed', onThemeChanged);
if (resizeObserver) {
resizeObserver.unobserve(this.element);
resizeObserver.disconnect();

View File

@@ -1,6 +1,8 @@
/* eslint no-underscore-dangle:0 */
import 'regenerator-runtime/runtime'; // Polyfill for using async/await
import themeFn from '@nebula.js/theme';
import localeFn from './locale';
import { createAppSelectionAPI } from './selections';
@@ -112,6 +114,8 @@ function nuked(configuration = {}, prev = {}) {
direction: configuration.direction,
});
const theme = themeFn();
const context = {
nebbie: null,
app,
@@ -119,6 +123,7 @@ function nuked(configuration = {}, prev = {}) {
logger,
types,
root,
theme: theme.externalAPI,
};
let selectionsApi = null;
@@ -142,6 +147,9 @@ function nuked(configuration = {}, prev = {}) {
*/
create: (createCfg, vizConfig) => create(createCfg, vizConfig, context),
theme(t) {
theme.internalAPI.setTheme({
type: t,
});
root.theme(t);
return api;
},

View File

@@ -7,7 +7,7 @@ import eventMixin from './selections/event-mixin';
const noopi = () => {};
export default function({ model, context, initialUserProps = {} } = {}) {
export default function({ model, context: nebulaContext, initialUserProps = {} } = {}) {
let reference = noopi;
let elementReference = null;
@@ -28,6 +28,7 @@ export default function({ model, context, initialUserProps = {} } = {}) {
let userProps = {
options: {},
context: {
theme: nebulaContext.theme,
permissions: [],
},
...initialUserProps,
@@ -58,6 +59,12 @@ export default function({ model, context, initialUserProps = {} } = {}) {
userProps = {
...userProps,
...up,
context: {
// DO NOT MAKE A DEEP COPY OF THEME AS IT WOULD MESS UP THE INSTANCE
...(userProps || {}).context,
...(up || {}).context,
theme: nebulaContext.theme,
},
};
update();
};
@@ -100,7 +107,7 @@ export default function({ model, context, initialUserProps = {} } = {}) {
element,
model,
api: cellApi,
nebulaContext: context,
nebulaContext,
onInitial: mounted,
});
return whenMounted;

30
apis/theme/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "@nebula.js/theme",
"version": "0.1.0-alpha.21",
"description": "",
"license": "MIT",
"author": "QlikTech International AB",
"keywords": [],
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/qlik-oss/nebula.js.git"
},
"main": "dist/theme.js",
"module": "dist/theme.esm.js",
"files": [
"dist"
],
"scripts": {
"build": "cross-env NODE_ENV=production rollup --config ../../rollup.config.js",
"build:dev": "rollup --config ../../rollup.config.js",
"build:watch": "rollup --config ../../rollup.config.js -w",
"prepublishOnly": "rm -rf dist && yarn run build"
},
"devDependencies": {
"extend": "^3.0.2",
"node-event-emitter": "^0.0.1"
}
}

100
apis/theme/src/index.js Normal file
View File

@@ -0,0 +1,100 @@
import extend from 'extend';
import EventEmitter from 'node-event-emitter';
import styleResolverFn from './style-resolver';
import paletteResolverFn from './paletter-resolver';
import baseRawJSON from './themes/base.json';
import lightRawJSON from './themes/light.json';
import darkRawJSON from './themes/dark.json';
export default function theme() {
let rawThemeJSON;
let resolvedThemeJSON;
let styleResolverInstanceCache = {};
let paletteResolver;
/**
* @interface
* @alias Theme
*/
const externalAPI = /** @lends Theme */ {
palettes(...a) {
return paletteResolver.palettes(...a);
},
dataScales(...a) {
return paletteResolver.dataScales(...a);
},
dataPalettes(...a) {
return paletteResolver.dataPalettes(...a);
},
uiPalettes(...a) {
return paletteResolver.uiPalettes(...a);
},
dataColors(...a) {
return paletteResolver.dataColors(...a);
},
/**
* Resolve a color object using the UI palette from the provided JSON theme
* @param {object} c
* @param {number=} c.index
* @param {string=} c.color
* @returns {string}
*
* @example
* theme.uiColor({ index: 1, color: 'red' });
*/
uiColor(...a) {
return paletteResolver.uiColor(...a);
},
/**
* Get the value of a style attribute in the theme by searching in the theme's json structure.
* The search starts at the specified base path and continue upwards until the value is found.
* If possible it will get the attribute's value using the given path.
*
* @param {string} basePath - Base path in the theme's json structure to start the search in (specified as a name path separated by dots)
* @param {string} path - Expected path for the attribute (specified as a name path separated by dots)
* @param {string} attribute - Name of the style attribute
* @return {string} The style value
*
* @example
* theme.getStyle('object', 'title.main', 'fontSize'));
* theme.getStyle('', '', 'fontSize'));
*/
getStyle(basePath, path, attribute) {
if (!styleResolverInstanceCache[basePath]) {
styleResolverInstanceCache[basePath] = styleResolverFn(basePath, resolvedThemeJSON);
}
return styleResolverInstanceCache[basePath].getStyle(path, attribute, false);
},
};
const internalAPI = {
/**
* @param {object} t Raw JSON theme
*/
setTheme(t) {
const colorRawJSON = t.type === 'dark' ? darkRawJSON : lightRawJSON;
rawThemeJSON = extend(true, {}, baseRawJSON, colorRawJSON, t);
styleResolverInstanceCache = {};
resolvedThemeJSON = styleResolverFn.resolveRawTheme(rawThemeJSON);
paletteResolver = paletteResolverFn(resolvedThemeJSON);
externalAPI.emit('changed');
},
};
Object.keys(EventEmitter.prototype).forEach(key => {
externalAPI[key] = EventEmitter.prototype[key];
});
EventEmitter.init(externalAPI);
internalAPI.setTheme({});
return {
externalAPI,
internalAPI,
};
}

View File

@@ -0,0 +1,84 @@
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 => {
pals.push({
key: s.propertyValue,
name: s.name,
translation: s.translation,
scheme: true, // indicate that this is scheme that can be used to generate more colors
type: s.type, // gradient, class, pyramid, row
colors: s.scale,
});
});
return pals;
},
dataPalettes() {
const pals = [];
resolvedTheme.palettes.data.forEach(s => {
pals.push({
key: s.propertyValue,
name: s.name,
translation: s.translation,
type: s.type,
colors: s.scale,
});
});
return pals;
},
uiPalettes() {
const pals = [];
resolvedTheme.palettes.ui.forEach(s => {
pals.push({
key: 'ui',
name: s.name,
translation: s.translation,
type: 'row',
colors: s.colors,
});
});
return pals;
},
dataColors() {
return {
primary: resolvedTheme.dataColors.primaryColor,
nil: resolvedTheme.dataColors.nullColor,
others: resolvedTheme.dataColors.othersColor,
};
},
uiColor(c) {
if (c.index < 0 || typeof c.index === 'undefined') {
return c.color;
}
if (typeof uiPalette === 'undefined') {
uiPalette = this.uiPalettes()[0] || false;
}
if (!uiPalette) {
return c.color;
}
if (typeof uiPalette.colors[c.index] === 'undefined') {
return c.color;
}
return uiPalette.colors[c.index];
},
};
}

View File

@@ -0,0 +1,141 @@
import extend from 'extend';
/**
* Creates the follwing array of paths
* object.barChart - legend.title - fontSize
* object - legend.title - fontSize
* legend.title - fontSize
* object.barChart - legend - fontSize
* object - legend - fontSize
* legend - fontSize
* object.barChart - fontSize
* object - fontSize
* fontSize
* @ignore
*/
function constructPaths(pathSteps, baseSteps) {
const ret = [];
let localBaseSteps;
let baseLength;
if (pathSteps) {
let pathLength = pathSteps.length;
while (pathLength >= 0) {
localBaseSteps = baseSteps.slice();
baseLength = localBaseSteps.length;
while (baseLength >= 0) {
ret.push(localBaseSteps.concat(pathSteps));
localBaseSteps.pop();
baseLength--;
}
pathSteps.pop();
pathLength--;
}
} else {
localBaseSteps = baseSteps.slice();
baseLength = localBaseSteps.length;
while (baseLength >= 0) {
ret.push(localBaseSteps.concat());
localBaseSteps.pop();
baseLength--;
}
}
return ret;
}
function getObject(root, steps) {
let obj = root;
for (let i = 0; i < steps.length; i++) {
if (obj[steps[i]]) {
obj = obj[steps[i]];
} else {
return null;
}
}
return obj;
}
function searchPathArray(pathArray, attribute, theme) {
for (let i = 0; i < pathArray.length; i++) {
const target = getObject(theme, pathArray[i]);
if (target !== null && target[attribute]) {
return target[attribute];
}
}
return undefined;
}
function searchValue(path, attribute, baseSteps, component) {
let pathArray;
if (path === '') {
pathArray = constructPaths(null, baseSteps);
} else {
const steps = path.split('.');
pathArray = constructPaths(steps, baseSteps);
}
return searchPathArray(pathArray, attribute, component);
}
export default function styleResolver(basePath, themeJSON) {
const basePathSteps = basePath.split('.');
const api = {
/**
*
* Get the value of a style attribute, starting in the given base path + path
* Ex: Base path: "object.barChart", Path: "legend.title", Attribute: "fontSize"
* Will search in, and fall back to:
* object.barChart - legend.title - fontSize
* object - legend.title - fontSize
* legend.title - fontSize
* object.barChart - legend - fontSize
* object - legend - fontSize
* legend - fontSize
* object.barChart - fontSize
* object - fontSize
* fontSize
* @ignore
*
* @param {string} component string of properties seperated by dots to search in
* @param {string} attribute to return
* @returns {any} value of the resolved path, undefined if not found
*/
getStyle(component, attribute) {
// TODO - object overrides
// TODO - feature flag on font-family?
// TODO - caching
const baseSteps = basePathSteps.concat();
const result = searchValue(component, attribute, baseSteps, themeJSON);
// TODO - support functions
return result;
},
};
return api;
}
/**
* Iterate the object tree and resolve variables and functions.
* @ignore
* @param {Object} - objTree
* @param {Object} - variables
*/
function resolveVariables(objTree, variables) {
Object.keys(objTree).forEach(key => {
if (typeof objTree[key] === 'object' && objTree[key] !== null) {
resolveVariables(objTree[key], variables);
} else if (typeof objTree[key] === 'string' && objTree[key].charAt(0) === '@') {
// Resolve variables
objTree[key] = variables[objTree[key]]; // eslint-disable-line no-param-reassign
}
});
}
styleResolver.resolveRawTheme = raw => {
// TODO - validate format
// TODO - generate class-pyramid
const c = extend(true, {}, raw);
resolveVariables(c, c._variables); // eslint-disable-line
return c;
};

View File

@@ -0,0 +1,102 @@
{
"fontSize": "13px",
"fontFamily": "'Source Sans Pro', 'Arial', 'sans-serif'",
"backgroundColor": "transparent",
"dataColors": {
"primaryColor": "#26a0a7",
"othersColor": "#a5a5a5",
"errorColor": "#ff4444",
"nullColor": "#d2d2d2"
},
"scales": [
{
"name": "Sequential Gradient",
"translation": "properties.colorScheme.sequential",
"type": "gradient",
"propertyValue": "sg",
"scale": [
"#26a0a7",
"#c7ea8b"
]
},
{
"name": "Sequential Classes",
"translation": "properties.colorScheme.sequentialC",
"propertyValue": "sc",
"type": "class",
"scale": [
"#26a0a7",
"#c7ea8b"
]
},
{
"name": "Diverging gradient",
"translation": "properties.colorScheme.diverging",
"propertyValue": "dg",
"type": "gradient",
"scale": [
"#26a0a7",
"#c3ea8c",
"#ec983d"
]
},
{
"name": "Diverging Classes",
"translation": "properties.colorScheme.divergingC",
"propertyValue": "dc",
"type": "class",
"scale": [
"#26a0a7",
"#c3ea8c",
"#ec983d"
]
}
],
"palettes": {
"data": [
{
"name": "12 Colors",
"translation": "properties.colorNumberOfColors.12",
"propertyValue": "12",
"type": "pyramid",
"scale": [
[ "#26A0A7" ],
[ "#26A0A7", "#EC983D" ],
[ "#26A0A7", "#CBE989", "#EC983D" ],
[ "#26A0A7", "#79D69F", "#F9EC86", "#EC983D" ],
[ "#26A0A7", "#79D69F", "#CBE989", "#F9EC86", "#EC983D" ],
[ "#26A0A7", "#65D3DA", "#79D69F", "#CBE989", "#F9EC86", "#EC983D" ],
[ "#26A0A7", "#65D3DA", "#79D69F", "#CBE989", "#F9EC86", "#EC983D", "#D76C6C" ],
[ "#26A0A7", "#65D3DA", "#79D69F", "#CBE989", "#F9EC86", "#FAD144", "#EC983D", "#D76C6C" ],
[ "#138185", "#26A0A7", "#65D3DA", "#79D69F", "#CBE989", "#F9EC86", "#FAD144", "#EC983D", "#D76C6C" ],
[ "#138185", "#26A0A7", "#65D3DA", "#79D69F", "#CBE989", "#EBF898", "#F9EC86", "#FAD144", "#EC983D", "#D76C6C" ],
[ "#138185", "#26A0A7", "#65D3DA", "#79D69F", "#CBE989", "#EBF898", "#F9EC86", "#FAD144", "#EC983D", "#D76C6C", "#A54343" ],
[ "#138185", "#26A0A7", "#65D3DA", "#79D69F", "#70BA6E", "#CBE989", "#EBF898", "#F9EC86", "#FAD144", "#EC983D", "#D76C6C", "#A54343" ]
]
}
],
"ui": [
{
"name": "Palette",
"colors": [
"#b0afae",
"#7b7a78",
"#a54343",
"#d76c6c",
"#ec983d",
"#ecc43d",
"#f9ec86",
"#cbe989",
"#70ba6e",
"#578b60",
"#79d69f",
"#26a0a7",
"#138185",
"#65d3da",
"#ffffff",
"#000000"
]
}
]
}
}

View File

@@ -0,0 +1,21 @@
{
"_variables": {
"@B20": "#333333",
"@B35": "#595959",
"@B45": "#737373",
"@B50": "#808080",
"@B60": "#999999",
"@B80": "#cccccc",
"@B90": "#e6e6e6",
"@B98": "#fbfbfb",
"@B100": "#ffffff",
"@H1": "24px",
"@H2": "18px",
"@H3": "14px",
"@H4": "13px",
"@H5": "12px",
"@H6": "10px"
},
"type": "dark",
"color": "@B98"
}

View File

@@ -0,0 +1,21 @@
{
"_variables": {
"@B20": "#333333",
"@B35": "#595959",
"@B45": "#737373",
"@B50": "#808080",
"@B60": "#999999",
"@B80": "#cccccc",
"@B90": "#e6e6e6",
"@B98": "#fbfbfb",
"@B100": "#ffffff",
"@H1": "24px",
"@H2": "18px",
"@H3": "14px",
"@H4": "13px",
"@H5": "12px",
"@H6": "10px"
},
"type": "light",
"color": "@B35"
}

View File

@@ -29,6 +29,7 @@ const cfg = ({ srcDir, distDir, snPath, dev = false }) => {
'@nebula.js/nucleus/src/object': path.resolve(process.cwd(), 'apis/nucleus/src/object'),
'@nebula.js/nucleus': path.resolve(process.cwd(), 'apis/nucleus/src'),
'@nebula.js/supernova': path.resolve(process.cwd(), 'apis/supernova/src'),
'@nebula.js/theme': path.resolve(process.cwd(), 'apis/theme/src'),
}
: {}),
},

View File

@@ -3,6 +3,7 @@ const nodeResolve = require('rollup-plugin-node-resolve');
const commonjs = require('rollup-plugin-commonjs');
const babel = require('rollup-plugin-babel');
const replace = require('rollup-plugin-replace');
const json = require('rollup-plugin-json');
const { terser } = require('rollup-plugin-terser');
const cwd = process.cwd();
@@ -102,6 +103,7 @@ const config = isEsm => {
nodeResolve({
extensions: ['.js', '.jsx'],
}),
json(),
commonjs({
namedExports: {
react: [

View File

@@ -10798,6 +10798,7 @@ rollup-plugin-dependency-flow@^0.3.0:
rollup-plugin-json@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/rollup-plugin-json/-/rollup-plugin-json-4.0.0.tgz#a18da0a4b30bf5ca1ee76ddb1422afbb84ae2b9e"
integrity sha512-hgb8N7Cgfw5SZAkb3jf0QXii6QX/FOkiIq2M7BAQIEydjHvTyxXHQiIzZaTFgx1GK0cRCHOCBHIyEkkLdWKxow==
dependencies:
rollup-pluginutils "^2.5.0"