Initial scaffolding (#1)
* populate with files copied from sdx-platform * some build fixes * Some little fixes to get started with initial scaffolding (#1) * patch to make tsc happy * fixing some typings * breakup theming somewhat and fix breaks * remove postbuild step for now * add basic docz inclusion * add start of a demo app * fix webpack + typescript integration for demo app * create web versions of native control set * split component infrastructure into separate packages * Split theme-registry out and add some documentation
This commit is contained in:
Родитель
f7b4446835
Коммит
74f321a5c3
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules/
|
||||||
|
common/temp/
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules
|
||||||
|
packages/*/lib
|
||||||
|
packages/*/dist
|
||||||
|
jest.config.js
|
||||||
|
packages/just-stack-monorepo/template/common/scripts
|
||||||
|
packages/example-lib
|
||||||
|
packages/documentation/website/build
|
|
@ -0,0 +1,53 @@
|
||||||
|
{
|
||||||
|
"extends": ["eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended"],
|
||||||
|
"env": {
|
||||||
|
"node": true,
|
||||||
|
"es6": true,
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": ["@typescript-eslint"],
|
||||||
|
"rules": {
|
||||||
|
"indent": "off",
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"quotes": [
|
||||||
|
"error",
|
||||||
|
"single",
|
||||||
|
{
|
||||||
|
"avoidEscape": true,
|
||||||
|
"allowTemplateLiterals": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"prefer-rest-params": "off",
|
||||||
|
"no-useless-escape": "off",
|
||||||
|
"require-atomic-updates": "off",
|
||||||
|
"@typescript-eslint/indent": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
|
"@typescript-eslint/no-use-before-define": "off",
|
||||||
|
"@typescript-eslint/no-object-literal-type-assertion": "off",
|
||||||
|
"@typescript-eslint/array-type": "off",
|
||||||
|
"@typescript-eslint/no-var-requires": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": "off"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.js",
|
||||||
|
"parserOptions": {
|
||||||
|
"sourceType": "script"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": "*.spec.js",
|
||||||
|
"env": {
|
||||||
|
"jest": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": "*.spec.{ts,js}",
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-non-null-assertion": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -36,6 +36,14 @@ build/Release
|
||||||
node_modules/
|
node_modules/
|
||||||
jspm_packages/
|
jspm_packages/
|
||||||
|
|
||||||
|
# Build targets
|
||||||
|
lib
|
||||||
|
lib-commonjs
|
||||||
|
lib-amd
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
temp
|
||||||
|
|
||||||
# TypeScript v1 declaration files
|
# TypeScript v1 declaration files
|
||||||
typings/
|
typings/
|
||||||
|
|
||||||
|
@ -59,3 +67,6 @@ typings/
|
||||||
|
|
||||||
# next.js build output
|
# next.js build output
|
||||||
.next
|
.next
|
||||||
|
|
||||||
|
# document generation directory
|
||||||
|
.docz
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Debug test",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${workspaceRoot}/node_modules/jest/bin/jest.js",
|
||||||
|
"cwd": "${workspaceFolder}/packages/just-scripts",
|
||||||
|
"args": ["-i", "--runInBand"],
|
||||||
|
"runtimeArgs": ["--nolazy"],
|
||||||
|
"env": {
|
||||||
|
"NODE_ENV": "development"
|
||||||
|
},
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"sourceMaps": true,
|
||||||
|
"outFiles": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.trimAutoWhitespace": true,
|
||||||
|
"editor.insertSpaces": true,
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
"files.trimTrailingWhitespace": true,
|
||||||
|
"json.format.enable": false,
|
||||||
|
"javascript.preferences.quoteStyle": "single",
|
||||||
|
"typescript.preferences.quoteStyle": "single",
|
||||||
|
"[handlebars]": {
|
||||||
|
"editor.formatOnSave": false
|
||||||
|
},
|
||||||
|
"files.associations": {
|
||||||
|
"**/package.json.hbs": "json",
|
||||||
|
"**/*.json.hbs": "jsonc",
|
||||||
|
"**/README.md.hbs": "markdown"
|
||||||
|
},
|
||||||
|
"search.exclude": {
|
||||||
|
"**/node_modules": true,
|
||||||
|
"**/lib": true
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
src
|
||||||
|
node_modules
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.editorconfig
|
||||||
|
config.js
|
||||||
|
jest.config.js
|
||||||
|
tslint.json
|
||||||
|
tsconfig.json
|
||||||
|
jsconfig.json
|
||||||
|
webpack.config.js
|
||||||
|
webpack.serve.config.js
|
||||||
|
*.build.log
|
|
@ -0,0 +1,8 @@
|
||||||
|
export default {
|
||||||
|
title: 'Documentation',
|
||||||
|
description: 'Documentation for the ui-fabric-react-native project',
|
||||||
|
typescript: true,
|
||||||
|
ignore: ['README.md', 'node_modules'],
|
||||||
|
src: '../packages',
|
||||||
|
dest: '/dist'
|
||||||
|
};
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "uifabric-react-native-docs",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "Documentation package",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/microsoft/ui-fabric-react-native"
|
||||||
|
},
|
||||||
|
"main": "lib/index.js",
|
||||||
|
"typings": "lib/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"docz:dev": "docz dev",
|
||||||
|
"docz:build": "docz build"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/es6-collections": "^0.5.29",
|
||||||
|
"@types/es6-promise": "0.0.32",
|
||||||
|
"@types/node": "^10.3.5",
|
||||||
|
"@types/jest": "^19.2.2",
|
||||||
|
"docz": "latest",
|
||||||
|
"docz-theme-default": "1.2.0"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"packages": ["packages/*", "scripts"],
|
||||||
|
"useWorkspaces": true,
|
||||||
|
"npmClient": "yarn",
|
||||||
|
"version": "0.0.0"
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"name": "uifabric-react-native",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "",
|
||||||
|
"keywords": [],
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "Jason Morse <jasonmo@microsoft.com>",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "lerna run --stream build",
|
||||||
|
"start": "node ./scripts/watch.js",
|
||||||
|
"test": "lerna run test",
|
||||||
|
"lint": "eslint packages --ext .ts,.js",
|
||||||
|
"docz:dev": "lerna run docz:dev"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@typescript-eslint/eslint-plugin": "^1.11.0",
|
||||||
|
"@typescript-eslint/parser": "^1.11.0",
|
||||||
|
"eslint": "^6.0.1",
|
||||||
|
"jest": "^24.8.0",
|
||||||
|
"just-scripts": "^0.26.0",
|
||||||
|
"just-task": "^0.13.1",
|
||||||
|
"lerna": "^3.16.4",
|
||||||
|
"typescript": "~3.4.4",
|
||||||
|
"webpack": "~4.29.5",
|
||||||
|
"webpack-cli": "^3.2.1",
|
||||||
|
"webpack-dev-server": "3.7.2"
|
||||||
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"documentation",
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
|
"repository": "https://github.com/Microsoft/ui-fabric-react-native.git"
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"presets": ["env", "react", "stage-2"]
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"extends": ["../../.eslintrc.json"],
|
||||||
|
"env": {
|
||||||
|
"browser": true
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
src
|
||||||
|
node_modules
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.editorconfig
|
||||||
|
config.js
|
||||||
|
jest.config.js
|
||||||
|
tslint.json
|
||||||
|
tsconfig.json
|
||||||
|
jsconfig.json
|
||||||
|
webpack.config.js
|
||||||
|
webpack.serve.config.js
|
||||||
|
*.build.log
|
|
@ -0,0 +1,10 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
Demo App
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="bundle.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1 @@
|
||||||
|
module.exports = require('../../scripts/jest.config');
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"name": "uifabric-rn-demo",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "Demo app for web code",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/microsoft/ui-fabric-react-native"
|
||||||
|
},
|
||||||
|
"main": "lib/index.tsx",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "webpack-dev-server --mode development",
|
||||||
|
"test": "jest",
|
||||||
|
"start-test": "jest --watch"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react": "16.8.3",
|
||||||
|
"react-dom": "16.8.3",
|
||||||
|
"experimental-web-controls": "0.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/es6-collections": "^0.5.29",
|
||||||
|
"@types/es6-promise": "0.0.32",
|
||||||
|
"@types/node": "^10.3.5",
|
||||||
|
"@types/jest": "^19.2.2",
|
||||||
|
"babel-core": "6.26.3",
|
||||||
|
"babel-loader": "8.0.6",
|
||||||
|
"babel-preset-env": "1.7.0",
|
||||||
|
"babel-preset-stage-2": "6.24.1",
|
||||||
|
"babel-preset-react": "6.24.1",
|
||||||
|
"ts-loader": "6.0.4"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Text, Pressable, Button, IPressableState, IPressableProps } from 'experimental-web-controls';
|
||||||
|
|
||||||
|
const _pressableRenderStyle: IPressableProps['renderStyle'] = (state: IPressableState) => {
|
||||||
|
return {
|
||||||
|
backgroundColor: state.pressed ? 'blue' : state.hovered ? 'red' : 'white',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'black',
|
||||||
|
display: 'flex'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const App: React.FunctionComponent = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Hello, world!!</h1>
|
||||||
|
<Pressable renderStyle={_pressableRenderStyle}>
|
||||||
|
<Text>Hello again</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Button content="Test Button" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
|
@ -0,0 +1,5 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as ReactDOM from 'react-dom';
|
||||||
|
import { App } from './App';
|
||||||
|
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root') as HTMLElement);
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "lib"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: './src/index.tsx',
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.(ts|tsx)$/,
|
||||||
|
loader: 'ts-loader',
|
||||||
|
exclude: /node_modules/
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
extensions: ['.ts', '.tsx', '.js', '.jsx'],
|
||||||
|
alias: {
|
||||||
|
react: path.resolve('./node_modules/react')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, '/dist'),
|
||||||
|
publicPath: '/',
|
||||||
|
filename: 'bundle.js'
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,13 @@
|
||||||
|
src
|
||||||
|
node_modules
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.editorconfig
|
||||||
|
config.js
|
||||||
|
jest.config.js
|
||||||
|
tslint.json
|
||||||
|
tsconfig.json
|
||||||
|
jsconfig.json
|
||||||
|
webpack.config.js
|
||||||
|
webpack.serve.config.js
|
||||||
|
*.build.log
|
|
@ -0,0 +1 @@
|
||||||
|
module.exports = require('../../scripts/jest.config');
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"name": "experimental-web-controls",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "Experimental web control implementations",
|
||||||
|
"private": true,
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/microsoft/ui-fabric-react-native"
|
||||||
|
},
|
||||||
|
"main": "lib/index.js",
|
||||||
|
"typings": "lib/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "tsc -w --preserveWatchOutput",
|
||||||
|
"test": "jest",
|
||||||
|
"start-test": "jest --watch"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@uifabric/theme-settings": "0.1.1",
|
||||||
|
"@uifabric/theming": "0.1.1",
|
||||||
|
"@uifabric/theming-react-native": "0.1.1",
|
||||||
|
"@uifabric/foundation-compose": "0.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/es6-collections": "^0.5.29",
|
||||||
|
"@types/es6-promise": "0.0.32",
|
||||||
|
"@types/node": "^10.3.5",
|
||||||
|
"@types/jest": "^19.2.2",
|
||||||
|
"react": "16.8.3",
|
||||||
|
"react-dom": "16.8.3"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { IButtonCustomizableProps, IButtonRenderData, IButtonSettings } from './Button.types';
|
||||||
|
import { renderSlot, IAsResolved } from '@uifabric/foundation-composable';
|
||||||
|
import { standardThemeSettings } from '@uifabric/foundation-compose';
|
||||||
|
import { finalizeSettings } from '@uifabric/theming-react-native';
|
||||||
|
import {
|
||||||
|
textTokenKeys,
|
||||||
|
foregroundColorKeys,
|
||||||
|
backgroundColorKeys,
|
||||||
|
borderKeys,
|
||||||
|
processBackgroundTokens,
|
||||||
|
processForegroundTokens,
|
||||||
|
processTextTokens,
|
||||||
|
processBorderTokens
|
||||||
|
} from '../tokens';
|
||||||
|
import { mergeSettings } from '@uifabric/theme-settings';
|
||||||
|
import { useAsPressable } from '../Pressable';
|
||||||
|
|
||||||
|
export function usePrepareState(data: IButtonRenderData): IButtonRenderData {
|
||||||
|
// create the button state/info once, re-renders happen with pressable state changes so this is storage
|
||||||
|
const { props, state } = useAsPressable(data.props);
|
||||||
|
data.props = props;
|
||||||
|
const newProps = data.props;
|
||||||
|
data.state.info = {
|
||||||
|
...state,
|
||||||
|
disabled: newProps.disabled,
|
||||||
|
content: !!newProps.content,
|
||||||
|
icon: !!newProps.icon
|
||||||
|
};
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function themeSettings(name: string, renderData: IButtonRenderData): IButtonRenderData {
|
||||||
|
return standardThemeSettings(name, renderData, renderData.state.info);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const keyProps: (keyof IButtonCustomizableProps)[] = [
|
||||||
|
'contentPadding',
|
||||||
|
'contentPaddingFocused',
|
||||||
|
'iconColor',
|
||||||
|
'iconColorHovered',
|
||||||
|
'iconColorPressed',
|
||||||
|
'iconSize',
|
||||||
|
'iconWeight'
|
||||||
|
].concat(textTokenKeys, foregroundColorKeys, backgroundColorKeys, borderKeys) as (keyof IButtonCustomizableProps)[];
|
||||||
|
|
||||||
|
export function processor(tokenProps: IButtonCustomizableProps, renderData: IButtonRenderData): IButtonSettings {
|
||||||
|
const baseSettings = {
|
||||||
|
root: {},
|
||||||
|
stack: {},
|
||||||
|
icon: {},
|
||||||
|
content: {}
|
||||||
|
};
|
||||||
|
processBackgroundTokens(tokenProps, baseSettings.root);
|
||||||
|
processForegroundTokens(tokenProps, baseSettings.icon, baseSettings.content);
|
||||||
|
processTextTokens(tokenProps, baseSettings.content);
|
||||||
|
processBorderTokens(tokenProps, baseSettings.root);
|
||||||
|
|
||||||
|
return mergeSettings<IButtonSettings>(renderData.slotProps, baseSettings, {
|
||||||
|
icon: {
|
||||||
|
style: {
|
||||||
|
overlayColor: tokenProps.iconColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finalizer(renderData: IButtonRenderData): IButtonRenderData {
|
||||||
|
const { props, slotProps, theme } = renderData;
|
||||||
|
const final: IButtonSettings = { root: props };
|
||||||
|
|
||||||
|
if (props.content) {
|
||||||
|
final.content = { children: props.content };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.icon) {
|
||||||
|
final.icon = { children: props.icon };
|
||||||
|
}
|
||||||
|
|
||||||
|
renderData.slotProps = finalizeSettings(theme, mergeSettings(slotProps, final));
|
||||||
|
return renderData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function view(result: IAsResolved<IButtonRenderData>, ...children: React.ReactNode[]): JSX.Element | null {
|
||||||
|
const slots = result.slots!;
|
||||||
|
const info = result.state.info;
|
||||||
|
const additionalChildren = children || [result.props.children];
|
||||||
|
|
||||||
|
return renderSlot(slots.root, info.icon && renderSlot(slots.icon), info.content && renderSlot(slots.content), ...additionalChildren);
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { IButtonSettings } from './Button.types';
|
||||||
|
import { augmentPlatformTheme } from '@uifabric/theming-react-native';
|
||||||
|
|
||||||
|
export function loadButtonSettings(): void {
|
||||||
|
const buttonSettings: { [key: string]: IButtonSettings } = {
|
||||||
|
RNFButton: {
|
||||||
|
root: {
|
||||||
|
backgroundColor: 'buttonBackground',
|
||||||
|
color: 'buttonText',
|
||||||
|
borderColor: 'buttonBorder',
|
||||||
|
borderWidth: 1,
|
||||||
|
fontSize: 'large',
|
||||||
|
fontWeight: 'semiBold',
|
||||||
|
fontFamily: 'primary',
|
||||||
|
horizontalAlign: 'center',
|
||||||
|
verticalAlign: 'center',
|
||||||
|
style: {
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
borderRadius: 2,
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderWidth: 1,
|
||||||
|
cursor: 'pointer',
|
||||||
|
lineHeight: 1,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
minHeight: 32,
|
||||||
|
minWidth: 100,
|
||||||
|
overflow: 'hidden',
|
||||||
|
paddingTop: 4,
|
||||||
|
paddingBottom: 4,
|
||||||
|
paddingLeft: 8,
|
||||||
|
paddingRight: 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content: {},
|
||||||
|
icon: {},
|
||||||
|
_precedence: ['disabled', 'hovered', 'pressed', 'focused'],
|
||||||
|
_overrides: {
|
||||||
|
disabled: {
|
||||||
|
root: {
|
||||||
|
backgroundColor: 'buttonBackgroundDisabled',
|
||||||
|
color: 'buttonTextDisabled',
|
||||||
|
borderColor: 'buttonBorderDisabled'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hovered: {
|
||||||
|
root: {
|
||||||
|
backgroundColor: 'buttonBackgroundHovered',
|
||||||
|
color: 'buttonTextHovered',
|
||||||
|
borderColor: 'buttonBorderHovered'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
root: {
|
||||||
|
backgroundColor: 'buttonBackgroundPressed',
|
||||||
|
color: 'buttonTextPressed',
|
||||||
|
borderColor: 'buttonBorderPressed'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
focused: {
|
||||||
|
root: {
|
||||||
|
borderColor: 'inputFocusBorderAlt'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
augmentPlatformTheme({ settings: buttonSettings });
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { IButtonComponent } from './Button.types';
|
||||||
|
import { compose } from '@uifabric/foundation-compose';
|
||||||
|
// import { Stack } from '../Stack';
|
||||||
|
import { Text } from '../Text';
|
||||||
|
import { keyProps, processor, finalizer, themeSettings, usePrepareState, view } from './Button.helpers';
|
||||||
|
import { loadButtonSettings } from './Button.settings';
|
||||||
|
import { Stack } from '../Stack';
|
||||||
|
|
||||||
|
loadButtonSettings();
|
||||||
|
|
||||||
|
export const Button = compose<IButtonComponent>({
|
||||||
|
className: 'RNFButton',
|
||||||
|
usePrepareState,
|
||||||
|
themeSettings,
|
||||||
|
tokenProcessors: [{ processor, keyProps }],
|
||||||
|
finalizer,
|
||||||
|
view,
|
||||||
|
slots: {
|
||||||
|
root: Stack,
|
||||||
|
icon: 'image',
|
||||||
|
content: Text
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Button;
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { IComponent, IRenderData } from '@uifabric/foundation-compose';
|
||||||
|
import { ITextProps } from '../Text';
|
||||||
|
import { IPressableState, IPressableProps } from '../Pressable';
|
||||||
|
import { IComponentSettings } from '@uifabric/theme-settings';
|
||||||
|
import { IForegroundColorTokens, IBackgroundColorTokens, IBorderTokens, ITextTokens } from '../tokens';
|
||||||
|
import { IImageProps } from '../htmlTypes';
|
||||||
|
import { IStackProps } from '../Stack';
|
||||||
|
|
||||||
|
export interface IButtonInfo extends IPressableState {
|
||||||
|
// whether this button is disabled
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
// whether icon or text is specified
|
||||||
|
icon?: boolean;
|
||||||
|
content?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Because state updates are coming from the touchable and will cause a child render the button doesn't use
|
||||||
|
* changes in state value to trigger re-render. The values inside inner are effectively mutable and are used
|
||||||
|
* for per-component storage
|
||||||
|
*/
|
||||||
|
export interface IButtonState {
|
||||||
|
info: IButtonInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IButtonTokens extends ITextTokens, IForegroundColorTokens, IBackgroundColorTokens, IBorderTokens {
|
||||||
|
/**
|
||||||
|
* Defines the padding of the Button, between the Button border and the Button contents.
|
||||||
|
*/
|
||||||
|
contentPadding?: number | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the padding of the Button, between the Button border and the Button contents, when the focus is on the Button.
|
||||||
|
*/
|
||||||
|
contentPaddingFocused?: number | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the icon color of the Button.
|
||||||
|
*/
|
||||||
|
iconColor?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the icon color of the Button when it is in a hovered state.
|
||||||
|
*/
|
||||||
|
iconColorHovered?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the icon color of the Button when it is in an active state.
|
||||||
|
*/
|
||||||
|
iconColorPressed?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the size of the icon inside the Button.
|
||||||
|
*/
|
||||||
|
iconSize?: number | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the font weight of the icon inside the Button.
|
||||||
|
*/
|
||||||
|
iconWeight?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If this button has text this should be set
|
||||||
|
*/
|
||||||
|
content?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If this button has an icon this is the source url or icon name
|
||||||
|
*/
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IButtonProps extends IPressableProps {
|
||||||
|
disabled?: boolean;
|
||||||
|
content?: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IButtonCustomizableProps = IButtonProps & IButtonTokens & IStackProps;
|
||||||
|
|
||||||
|
export type IButtonSettings = IComponentSettings<{
|
||||||
|
root: IButtonCustomizableProps;
|
||||||
|
icon: IImageProps;
|
||||||
|
content: ITextProps;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type IButtonComponent = IComponent<IButtonProps, IButtonSettings, IButtonCustomizableProps, IButtonState>;
|
||||||
|
export type IButtonRenderData = IRenderData<IButtonCustomizableProps, IButtonSettings, IButtonState>;
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './Button.types';
|
||||||
|
export * from './Button';
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { IComponent, IRenderData } from '@uifabric/foundation-compose';
|
||||||
|
import { IComponentSettings, IStyleProp } from '@uifabric/theme-settings';
|
||||||
|
import { IDivProps, ICSSStyle } from '../htmlTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used by IRenderChild, it simply describes a function that takes
|
||||||
|
* some generic state type T and returns a ReactNode
|
||||||
|
*/
|
||||||
|
export type IChildAsFunction<T> = (state: T) => React.ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An IRenderChild describes children as a function that take the current
|
||||||
|
* state of the parent component. It is up to the parent to invoke the function
|
||||||
|
* and make proper use of the more typical ReactNode object that is returned
|
||||||
|
* This is an especially helpful construct when children of a Touchable require
|
||||||
|
* knowledge of the interaction state of their parent to properly render themselves
|
||||||
|
* (e.g. foreground color of a text child)
|
||||||
|
*/
|
||||||
|
export type IRenderChild<T> = IChildAsFunction<T> | React.ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An IRenderStyle describes style as a function that takes the current
|
||||||
|
* state of the parent component. It is up to the parent to invoke the function
|
||||||
|
* and make proper use of the more typical StyleProp<S> object that is returned
|
||||||
|
* This is convenient for when styles need to be calculated depending on interaction states.
|
||||||
|
*/
|
||||||
|
export type IRenderStyle<T, S> = (state: T) => IStyleProp<S>;
|
||||||
|
|
||||||
|
export interface IPressableState {
|
||||||
|
pressed?: boolean;
|
||||||
|
focused?: boolean;
|
||||||
|
hovered?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPressableHooks {
|
||||||
|
state: IPressableState;
|
||||||
|
setState: (newState: IPressableState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IWithOnStateChange<T extends object> = T & { onStateChange?: (newState: IPressableState) => void };
|
||||||
|
|
||||||
|
export interface IPressableProps extends IDivProps {
|
||||||
|
disabled?: boolean;
|
||||||
|
children?: IRenderChild<IPressableState>;
|
||||||
|
// Typescript will not allow an extension of the IView* interface
|
||||||
|
// that allows style to take on a function value. This is not a problem
|
||||||
|
// with children, presumably because function components are valid as children.
|
||||||
|
// As such, a renderStyle prop that takes a function value is provided
|
||||||
|
// instead, in conjunction with the base style prop (StyleProp<ViewStyle>).
|
||||||
|
// The style prop will only be used if a renderStyle is not provided.
|
||||||
|
renderStyle?: IRenderStyle<IPressableState, ICSSStyle>;
|
||||||
|
onStateChange?: (newState: IPressableState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IPressableSettings = IComponentSettings<{ root: IPressableProps }>;
|
||||||
|
export type IPressableRenderData = IRenderData<IPressableProps, IPressableSettings, IPressableHooks>;
|
||||||
|
export type IPressableComponent = IComponent<IPressableProps, IPressableSettings, IPressableProps, IPressableHooks>;
|
|
@ -0,0 +1,34 @@
|
||||||
|
/**
|
||||||
|
* This is primarily a fork of React Native's Touchable Mixin.
|
||||||
|
* It has been repurposed as it's own standalone control for win32,
|
||||||
|
* as it needs to support a richer set of functionality on the desktop.
|
||||||
|
* The touchable variants can be rewritten as wrappers around TouchableWin32
|
||||||
|
* by passing the correct set of props down and managing state correctly.
|
||||||
|
*
|
||||||
|
* React Native's Touchable.js file (https://github.com/facebook/react-native/blob/master/Libraries/Components/Touchable/Touchable.js)
|
||||||
|
* provides an overview over how touchables work and interact with the gesture responder system.
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import { compose } from '@uifabric/foundation-compose';
|
||||||
|
import { IPressableComponent, IPressableRenderData } from './Pressable.props';
|
||||||
|
import { mergeSettings } from '@uifabric/theme-settings';
|
||||||
|
import { useWebPressable } from './useAsPressable';
|
||||||
|
|
||||||
|
function finalizer(renderData: IPressableRenderData): IPressableRenderData {
|
||||||
|
const { state, props } = renderData;
|
||||||
|
const extraStyle = props.renderStyle ? props.renderStyle(state.state) : {};
|
||||||
|
renderData.slotProps = mergeSettings(renderData.slotProps, { root: renderData.props }, { root: { style: extraStyle } });
|
||||||
|
return renderData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Pressable = compose<IPressableComponent>({
|
||||||
|
className: 'RNFPressable',
|
||||||
|
usePrepareState: useWebPressable,
|
||||||
|
finalizer,
|
||||||
|
slots: {
|
||||||
|
root: 'div'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Pressable;
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './Pressable.props';
|
||||||
|
export * from './Pressable';
|
||||||
|
export * from './useAsPressable';
|
|
@ -0,0 +1,43 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { IPressableRenderData, IPressableState, IWithOnStateChange } from './Pressable.props';
|
||||||
|
import { IDivProps } from '../htmlTypes';
|
||||||
|
|
||||||
|
export function useAsPressable(
|
||||||
|
props: IWithOnStateChange<IDivProps>
|
||||||
|
): { props: IWithOnStateChange<IDivProps>; state: IPressableState; setState: (partial: IPressableState) => void } {
|
||||||
|
const [state, setState] = React.useState({ pressed: false, hovered: false, focused: false } as IPressableState);
|
||||||
|
const onSetState = React.useCallback(
|
||||||
|
(partialState: IPressableState) => {
|
||||||
|
const newState = { ...state, ...partialState };
|
||||||
|
setState(newState);
|
||||||
|
if (props.onStateChange) {
|
||||||
|
props.onStateChange(newState);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state, setState, props.onStateChange]
|
||||||
|
);
|
||||||
|
const onMouseEnter = React.useCallback(_e => onSetState({ hovered: true }), [onSetState]);
|
||||||
|
const onMouseLeave = React.useCallback(_e => onSetState({ hovered: false }), [onSetState]);
|
||||||
|
const onMouseDown = React.useCallback(_e => onSetState({ pressed: true }), [onSetState]);
|
||||||
|
const onMouseUp = React.useCallback(_e => onSetState({ pressed: false }), [onSetState]);
|
||||||
|
const onFocus = React.useCallback(_e => onSetState({ focused: true }), [onSetState]);
|
||||||
|
const onBlur = React.useCallback(_e => onSetState({ focused: false }), [onSetState]);
|
||||||
|
const newProps = {
|
||||||
|
...props,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
onMouseDown,
|
||||||
|
onMouseUp,
|
||||||
|
onFocus,
|
||||||
|
onBlur
|
||||||
|
};
|
||||||
|
|
||||||
|
return { props: newProps, state, setState: onSetState };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebPressable(data: IPressableRenderData): IPressableRenderData {
|
||||||
|
const { props, state, setState } = useAsPressable(data.props);
|
||||||
|
data.state = { state, setState };
|
||||||
|
data.props = props;
|
||||||
|
return data;
|
||||||
|
}
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { IStackSettings, IStackProps, IStackRenderData } from './Stack.types';
|
||||||
|
import { parseGap, parsePadding } from './StackUtils';
|
||||||
|
import { augmentPlatformTheme } from '@uifabric/theming-react-native';
|
||||||
|
import { mergeSettings } from '@uifabric/theme-settings';
|
||||||
|
|
||||||
|
const nameMap: { [key: string]: string } = {
|
||||||
|
start: 'flex-start',
|
||||||
|
end: 'flex-end'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadStackSettings(): void {
|
||||||
|
const settings: IStackSettings = {
|
||||||
|
root: {
|
||||||
|
style: {
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
width: 'auto',
|
||||||
|
overflow: 'visible',
|
||||||
|
height: '100%'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
inner: {
|
||||||
|
style: {
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
overflow: 'visible',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
maxWidth: '100vw'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
augmentPlatformTheme({
|
||||||
|
settings: {
|
||||||
|
RNFStack: settings
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const keyProps: (keyof IStackProps)[] = [
|
||||||
|
'verticalFill',
|
||||||
|
'horizontal',
|
||||||
|
'reversed',
|
||||||
|
'gap',
|
||||||
|
'grow',
|
||||||
|
'wrap',
|
||||||
|
'horizontalAlign',
|
||||||
|
'verticalAlign',
|
||||||
|
'disableShrink',
|
||||||
|
'childrenGap',
|
||||||
|
'maxHeight',
|
||||||
|
'maxWidth',
|
||||||
|
'padding'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function processor(tokenProps: IStackProps, renderData: IStackRenderData): IStackSettings {
|
||||||
|
const {
|
||||||
|
verticalFill,
|
||||||
|
horizontal,
|
||||||
|
reversed,
|
||||||
|
gap,
|
||||||
|
grow,
|
||||||
|
wrap,
|
||||||
|
horizontalAlign,
|
||||||
|
verticalAlign,
|
||||||
|
disableShrink,
|
||||||
|
maxHeight,
|
||||||
|
maxWidth,
|
||||||
|
padding
|
||||||
|
} = tokenProps;
|
||||||
|
let childrenGap = tokenProps.childrenGap || gap;
|
||||||
|
const { rowGap, columnGap } = parseGap(childrenGap, renderData.theme);
|
||||||
|
const horizontalMargin = `${-0.5 * columnGap.value}${columnGap.unit}`;
|
||||||
|
const verticalMargin = `${-0.5 * rowGap.value}${rowGap.unit}`;
|
||||||
|
const theme = renderData.theme;
|
||||||
|
|
||||||
|
// styles to be applied to all direct children regardless of wrap or direction
|
||||||
|
const childStyles = {
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
};
|
||||||
|
|
||||||
|
// selectors to be applied regardless of wrap or direction
|
||||||
|
const commonSelectors = {
|
||||||
|
// flexShrink styles are applied by the StackItem
|
||||||
|
'> *:not(.ms-StackItem)': {
|
||||||
|
flexShrink: disableShrink ? 0 : 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (wrap) {
|
||||||
|
const newSettings: IStackSettings = ({
|
||||||
|
root: {
|
||||||
|
style: [
|
||||||
|
{
|
||||||
|
maxWidth,
|
||||||
|
maxHeight
|
||||||
|
},
|
||||||
|
horizontalAlign && {
|
||||||
|
[horizontal ? 'justifyContent' : 'alignItems']: nameMap[horizontalAlign] || horizontalAlign
|
||||||
|
},
|
||||||
|
verticalAlign && {
|
||||||
|
[horizontal ? 'alignItems' : 'justifyContent']: nameMap[verticalAlign] || verticalAlign
|
||||||
|
},
|
||||||
|
horizontal && {
|
||||||
|
height: verticalFill ? '100%' : 'auto'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
inner: {
|
||||||
|
style: [
|
||||||
|
{
|
||||||
|
marginLeft: horizontalMargin,
|
||||||
|
marginRight: horizontalMargin,
|
||||||
|
marginTop: verticalMargin,
|
||||||
|
marginBottom: verticalMargin,
|
||||||
|
padding: parsePadding(padding, theme),
|
||||||
|
// avoid unnecessary calc() calls if horizontal gap is 0
|
||||||
|
width: columnGap.value === 0 ? '100%' : `calc(100% + ${columnGap.value}${columnGap.unit})`,
|
||||||
|
maxWidth: '100vw',
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'> *': {
|
||||||
|
margin: `${0.5 * rowGap.value}${rowGap.unit} ${0.5 * columnGap.value}${columnGap.unit}`,
|
||||||
|
|
||||||
|
...childStyles
|
||||||
|
},
|
||||||
|
...commonSelectors
|
||||||
|
}
|
||||||
|
},
|
||||||
|
horizontalAlign && {
|
||||||
|
[horizontal ? 'justifyContent' : 'alignItems']: nameMap[horizontalAlign] || horizontalAlign
|
||||||
|
},
|
||||||
|
verticalAlign && {
|
||||||
|
[horizontal ? 'alignItems' : 'justifyContent']: nameMap[verticalAlign] || verticalAlign
|
||||||
|
},
|
||||||
|
horizontal && {
|
||||||
|
flexDirection: reversed ? 'row-reverse' : 'row',
|
||||||
|
|
||||||
|
// avoid unnecessary calc() calls if vertical gap is 0
|
||||||
|
height: rowGap.value === 0 ? '100%' : `calc(100% + ${rowGap.value}${rowGap.unit})`,
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'> *': {
|
||||||
|
maxWidth: columnGap.value === 0 ? '100%' : `calc(100% - ${columnGap.value}${columnGap.unit})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
!horizontal && {
|
||||||
|
flexDirection: reversed ? 'column-reverse' : 'column',
|
||||||
|
height: `calc(100% + ${rowGap.value}${rowGap.unit})`,
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'> *': {
|
||||||
|
maxHeight: rowGap.value === 0 ? '100%' : `calc(100% - ${rowGap.value}${rowGap.unit})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} as unknown) as IStackSettings;
|
||||||
|
renderData.slotProps = mergeSettings(renderData.slotProps, newSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
root: {
|
||||||
|
style: [
|
||||||
|
{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: horizontal ? (reversed ? 'row-reverse' : 'row') : reversed ? 'column-reverse' : 'column',
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
width: 'auto',
|
||||||
|
height: verticalFill ? '100%' : 'auto',
|
||||||
|
maxWidth,
|
||||||
|
maxHeight,
|
||||||
|
padding: parsePadding(padding, theme),
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'> *': childStyles,
|
||||||
|
|
||||||
|
// apply gap margin to every direct child except the first direct child if the direction is not reversed,
|
||||||
|
// and the last direct one if it is
|
||||||
|
[reversed ? '> *:not(:last-child)' : '> *:not(:first-child)']: [
|
||||||
|
horizontal && {
|
||||||
|
marginLeft: `${columnGap.value}${columnGap.unit}`
|
||||||
|
},
|
||||||
|
!horizontal && {
|
||||||
|
marginTop: `${rowGap.value}${rowGap.unit}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
...commonSelectors
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grow && {
|
||||||
|
flexGrow: grow === true ? 1 : grow,
|
||||||
|
overflow: 'hidden'
|
||||||
|
},
|
||||||
|
horizontalAlign && {
|
||||||
|
[horizontal ? 'justifyContent' : 'alignItems']: nameMap[horizontalAlign] || horizontalAlign
|
||||||
|
},
|
||||||
|
verticalAlign && {
|
||||||
|
[horizontal ? 'alignItems' : 'justifyContent']: nameMap[verticalAlign] || verticalAlign
|
||||||
|
}
|
||||||
|
]
|
||||||
|
// TODO: this cast may be hiding some potential issues with styling and name
|
||||||
|
// lookups and should be removed
|
||||||
|
}
|
||||||
|
} as IStackSettings;
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { keyProps, processor } from './Stack.styles';
|
||||||
|
import { IStackComponent, IStackRenderData } from './Stack.types';
|
||||||
|
import { StackItem } from './StackItem/StackItem';
|
||||||
|
import { compose } from '@uifabric/foundation-compose';
|
||||||
|
import { renderSlot, IAsResolved } from '@uifabric/foundation-composable';
|
||||||
|
|
||||||
|
const view: IStackComponent['view'] = (renderData: IAsResolved<IStackRenderData>, ...children: React.ReactNode[]) => {
|
||||||
|
const { props, slots } = renderData;
|
||||||
|
const { wrap } = props;
|
||||||
|
const inputChildren = children || props.children;
|
||||||
|
|
||||||
|
if (wrap) {
|
||||||
|
return renderSlot(slots.root, renderSlot(slots.inner, inputChildren));
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderSlot(slots.root, inputChildren);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StackStatics = {
|
||||||
|
Item: StackItem
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Stack = compose<IStackComponent>({
|
||||||
|
className: 'RNFStack',
|
||||||
|
tokenProcessors: [{ keyProps, processor }],
|
||||||
|
statics: StackStatics,
|
||||||
|
slots: {
|
||||||
|
root: 'div',
|
||||||
|
inner: 'div'
|
||||||
|
},
|
||||||
|
view
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Stack;
|
|
@ -0,0 +1,138 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ITextTokens, IBackgroundColorTokens, IBorderTokens } from '../tokens';
|
||||||
|
import { IComponentSettings } from '@uifabric/theme-settings';
|
||||||
|
import { IComponent, IRenderData } from '@uifabric/foundation-compose';
|
||||||
|
import { IDivProps } from '../htmlTypes';
|
||||||
|
import { IStackItemProps } from './StackItem/StackItem.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines a type made by the union of the different values that the align-items and justify-content flexbox
|
||||||
|
* properties can take.
|
||||||
|
* {@docCategory Stack}
|
||||||
|
*/
|
||||||
|
export type Alignment = 'start' | 'end' | 'center' | 'space-between' | 'space-around' | 'space-evenly' | 'baseline' | 'stretch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@docCategory Stack}
|
||||||
|
*/
|
||||||
|
export interface IStackProps extends IStackTokens, IDivProps {
|
||||||
|
/**
|
||||||
|
* Defines how to render the Stack.
|
||||||
|
*/
|
||||||
|
as?: React.ReactType<React.HTMLAttributes<HTMLElement>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines whether to render Stack children horizontally.
|
||||||
|
* @defaultvalue false
|
||||||
|
*/
|
||||||
|
horizontal?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines whether to render Stack children in the opposite direction (bottom-to-top if it's a vertical Stack and
|
||||||
|
* right-to-left if it's a horizontal Stack).
|
||||||
|
* @defaultvalue false
|
||||||
|
*/
|
||||||
|
reversed?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how to align Stack children horizontally (along the x-axis).
|
||||||
|
*/
|
||||||
|
horizontalAlign?: Alignment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how to align Stack children vertically (along the y-axis).
|
||||||
|
*/
|
||||||
|
verticalAlign?: Alignment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines whether the Stack should take up 100% of the height of its parent.
|
||||||
|
* This property is required to be set to true when using the `grow` flag on children in vertical oriented Stacks.
|
||||||
|
* Stacks are rendered as block elements and grow horizontally to the container already.
|
||||||
|
* @defaultvalue false
|
||||||
|
*/
|
||||||
|
verticalFill?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines whether Stack children should not shrink to fit the available space.
|
||||||
|
* @defaultvalue false
|
||||||
|
*/
|
||||||
|
disableShrink?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how much to grow the Stack in proportion to its siblings.
|
||||||
|
*/
|
||||||
|
grow?: boolean | number | 'inherit' | 'initial' | 'unset';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the spacing between Stack children.
|
||||||
|
* The property is specified as a value for 'row gap', followed optionally by a value for 'column gap'.
|
||||||
|
* If 'column gap' is omitted, it's set to the same value as 'row gap'.
|
||||||
|
* @deprecated Use 'childrenGap' token instead.
|
||||||
|
*/
|
||||||
|
gap?: number | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the maximum width that the Stack can take.
|
||||||
|
* @deprecated Use 'maxWidth' token instead.
|
||||||
|
*/
|
||||||
|
maxWidth?: number | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the maximum height that the Stack can take.
|
||||||
|
* @deprecated Use 'maxHeight' token instead.
|
||||||
|
*/
|
||||||
|
maxHeight?: number | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the inner padding of the Stack.
|
||||||
|
* @deprecated Use 'padding' token instead.
|
||||||
|
*/
|
||||||
|
padding?: number | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines whether Stack children should wrap onto multiple rows or columns when they are about to overflow
|
||||||
|
* the size of the Stack.
|
||||||
|
* @defaultvalue false
|
||||||
|
*/
|
||||||
|
wrap?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@docCategory Stack}
|
||||||
|
*/
|
||||||
|
export interface IStackTokens extends ITextTokens, IBackgroundColorTokens, IBorderTokens {
|
||||||
|
/**
|
||||||
|
* Defines the spacing between Stack children.
|
||||||
|
* The property is specified as a value for 'row gap', followed optionally by a value for 'column gap'.
|
||||||
|
* If 'column gap' is omitted, it's set to the same value as 'row gap'.
|
||||||
|
*/
|
||||||
|
childrenGap?: number | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines a maximum height for the Stack.
|
||||||
|
*/
|
||||||
|
maxHeight?: number | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines a maximum width for the Stack.
|
||||||
|
*/
|
||||||
|
maxWidth?: number | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the padding to be applied to the Stack contents relative to its border.
|
||||||
|
*/
|
||||||
|
padding?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IStackSettings = IComponentSettings<{
|
||||||
|
root: IStackProps;
|
||||||
|
inner: IDivProps;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export interface IStackStatics {
|
||||||
|
Item: React.FunctionComponent<IStackItemProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* tslint:disable:no-any */
|
||||||
|
export type IStackComponent = IComponent<IStackProps, IStackSettings, IStackProps, any, IStackStatics>;
|
||||||
|
export type IStackRenderData = IRenderData<IStackProps, IStackSettings>;
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { IStackItemProps, IStackItemRenderData, IStackItemSettings } from './StackItem.types';
|
||||||
|
import { mergeSettings } from '@uifabric/theme-settings';
|
||||||
|
|
||||||
|
const alignMap: { [key: string]: string } = {
|
||||||
|
start: 'flex-start',
|
||||||
|
end: 'flex-end'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const keyProps: (keyof IStackItemProps)[] = ['grow', 'shrink', 'disableShrink', 'align', 'verticalFill', 'margin'];
|
||||||
|
|
||||||
|
export function processor(tokenProps: IStackItemProps, renderData: IStackItemRenderData): IStackItemSettings {
|
||||||
|
const { grow, shrink, disableShrink, align, verticalFill, margin } = tokenProps;
|
||||||
|
const newSettings: IStackItemSettings = {
|
||||||
|
root: {
|
||||||
|
style: [
|
||||||
|
{
|
||||||
|
margin,
|
||||||
|
height: verticalFill ? '100%' : 'auto',
|
||||||
|
width: 'auto'
|
||||||
|
},
|
||||||
|
grow && { flexGrow: grow === true ? 1 : grow },
|
||||||
|
(disableShrink || (!grow && !shrink)) && {
|
||||||
|
flexShrink: 0
|
||||||
|
},
|
||||||
|
shrink &&
|
||||||
|
!disableShrink && {
|
||||||
|
flexShrink: 1
|
||||||
|
},
|
||||||
|
align && {
|
||||||
|
alignSelf: alignMap[align] || align
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mergeSettings(renderData.slotProps, newSettings);
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { IStackItemComponent } from './StackItem.types';
|
||||||
|
import { compose } from '@uifabric/foundation-compose';
|
||||||
|
import { keyProps, processor } from './StackItem.tokens';
|
||||||
|
|
||||||
|
export const StackItem = compose<IStackItemComponent>({
|
||||||
|
className: 'RNFStackItem',
|
||||||
|
tokenProcessors: [{ keyProps, processor }],
|
||||||
|
slots: {
|
||||||
|
root: 'div'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default StackItem;
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { IComponentSettings, IStyleProp } from '@uifabric/theme-settings';
|
||||||
|
import { IComponent, IRenderData } from '@uifabric/foundation-compose';
|
||||||
|
import { ICSSStyle } from '../../htmlTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@docCategory Stack}
|
||||||
|
*/
|
||||||
|
export interface IStackItemTokens {
|
||||||
|
/**
|
||||||
|
* Defines the margin to be applied to the StackItem relative to its container.
|
||||||
|
*/
|
||||||
|
margin?: number | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the padding to be applied to the StackItem contents relative to its border.
|
||||||
|
*/
|
||||||
|
padding?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@docCategory Stack}
|
||||||
|
*/
|
||||||
|
export interface IStackItemProps extends IStackItemTokens {
|
||||||
|
/**
|
||||||
|
* Defines how much to grow the StackItem in proportion to its siblings.
|
||||||
|
*/
|
||||||
|
grow?: boolean | number | 'inherit' | 'initial' | 'unset';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines at what ratio should the StackItem shrink to fit the available space.
|
||||||
|
*/
|
||||||
|
shrink?: boolean | number | 'inherit' | 'initial' | 'unset';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines whether the StackItem should be prevented from shrinking.
|
||||||
|
* This can be used to prevent a StackItem from shrinking when it is inside of a Stack that has shrinking items.
|
||||||
|
* @defaultvalue false
|
||||||
|
*/
|
||||||
|
disableShrink?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how to align the StackItem along the x-axis (for vertical Stacks) or the y-axis (for horizontal Stacks).
|
||||||
|
*/
|
||||||
|
align?: 'auto' | 'stretch' | 'baseline' | 'start' | 'center' | 'end';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines whether the StackItem should take up 100% of the height of its parent.
|
||||||
|
* @defaultvalue true
|
||||||
|
*/
|
||||||
|
verticalFill?: boolean;
|
||||||
|
|
||||||
|
style?: IStyleProp<ICSSStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IStackItemSettings = IComponentSettings<{
|
||||||
|
root: IStackItemProps;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type IStackItemComponent = IComponent<IStackItemProps, IStackItemSettings>;
|
||||||
|
export type IStackItemRenderData = IRenderData<IStackItemProps, IStackItemSettings>;
|
|
@ -0,0 +1,118 @@
|
||||||
|
/**
|
||||||
|
* Functions used by Stack components to simplify style-related computations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ITheme } from '@uifabric/theming';
|
||||||
|
import { IStackProps } from './Stack.types';
|
||||||
|
|
||||||
|
// Helper function that converts a themed spacing key (if given) to the corresponding themed spacing value.
|
||||||
|
const _getThemedSpacing = (space: string, theme: ITheme): string => {
|
||||||
|
if (theme.spacing.hasOwnProperty(space)) {
|
||||||
|
return theme.spacing[space as keyof typeof theme.spacing];
|
||||||
|
}
|
||||||
|
return space;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function that takes a gap as a string and converts it into a { value, unit } representation.
|
||||||
|
const _getValueUnitGap = (gap: string): { value: number; unit: string } => {
|
||||||
|
const numericalPart = parseFloat(gap);
|
||||||
|
const numericalValue = isNaN(numericalPart) ? 0 : numericalPart;
|
||||||
|
const numericalString = isNaN(numericalPart) ? '' : numericalPart.toString();
|
||||||
|
|
||||||
|
const unitPart = gap.substring(numericalString.toString().length);
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: numericalValue,
|
||||||
|
unit: unitPart || 'px'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes in a gap size in either a CSS-style format (e.g. 10 or "10px")
|
||||||
|
* or a key of a themed spacing value (e.g. "s1").
|
||||||
|
* Returns the separate numerical value of the padding (e.g. 10)
|
||||||
|
* and the CSS unit (e.g. "px").
|
||||||
|
*/
|
||||||
|
export const parseGap = (
|
||||||
|
gap: IStackProps['gap'],
|
||||||
|
theme: ITheme
|
||||||
|
): { rowGap: { value: number; unit: string }; columnGap: { value: number; unit: string } } => {
|
||||||
|
if (gap === undefined || gap === '') {
|
||||||
|
return {
|
||||||
|
rowGap: {
|
||||||
|
value: 0,
|
||||||
|
unit: 'px'
|
||||||
|
},
|
||||||
|
columnGap: {
|
||||||
|
value: 0,
|
||||||
|
unit: 'px'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof gap === 'number') {
|
||||||
|
return {
|
||||||
|
rowGap: {
|
||||||
|
value: gap,
|
||||||
|
unit: 'px'
|
||||||
|
},
|
||||||
|
columnGap: {
|
||||||
|
value: gap,
|
||||||
|
unit: 'px'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitGap = gap.split(' ');
|
||||||
|
|
||||||
|
// If the array has more than two values, then return 0px.
|
||||||
|
if (splitGap.length > 2) {
|
||||||
|
return {
|
||||||
|
rowGap: {
|
||||||
|
value: 0,
|
||||||
|
unit: 'px'
|
||||||
|
},
|
||||||
|
columnGap: {
|
||||||
|
value: 0,
|
||||||
|
unit: 'px'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the array has two values, then parse each one.
|
||||||
|
if (splitGap.length === 2) {
|
||||||
|
return {
|
||||||
|
rowGap: _getValueUnitGap(_getThemedSpacing(splitGap[0], theme)),
|
||||||
|
columnGap: _getValueUnitGap(_getThemedSpacing(splitGap[1], theme))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Else, parse the numerical value and pass it as both the vertical and horizontal gap.
|
||||||
|
const calculatedGap = _getValueUnitGap(_getThemedSpacing(gap, theme));
|
||||||
|
|
||||||
|
return {
|
||||||
|
rowGap: calculatedGap,
|
||||||
|
columnGap: calculatedGap
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes in a padding in a CSS-style format (e.g. 10, "10px", "10px 10px", etc.)
|
||||||
|
* where the separate padding values can also be the key of a themed spacing value
|
||||||
|
* (e.g. "s1 m", "10px l1 20px l2", etc.).
|
||||||
|
* Returns a CSS-style padding.
|
||||||
|
*/
|
||||||
|
export const parsePadding = (padding: number | string | undefined, theme: ITheme): number | string | undefined => {
|
||||||
|
if (padding === undefined || typeof padding === 'number' || padding === '') {
|
||||||
|
return padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddingValues = padding.split(' ');
|
||||||
|
if (paddingValues.length < 2) {
|
||||||
|
return _getThemedSpacing(padding, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paddingValues.reduce((padding1: string, padding2: string) => {
|
||||||
|
return _getThemedSpacing(padding1, theme) + ' ' + _getThemedSpacing(padding2, theme);
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './StackItem/StackItem';
|
||||||
|
export * from './StackItem/StackItem.types';
|
||||||
|
export * from './Stack';
|
||||||
|
export * from './Stack.types';
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { ITextSettings } from './Text.types';
|
||||||
|
import { augmentPlatformTheme } from '@uifabric/theming-react-native';
|
||||||
|
|
||||||
|
export function loadTextSettings(): void {
|
||||||
|
const textSettings: ITextSettings = {
|
||||||
|
root: {
|
||||||
|
fontFamily: 'primary',
|
||||||
|
fontSize: 'medium',
|
||||||
|
fontWeight: 'medium',
|
||||||
|
color: 'bodyText',
|
||||||
|
style: {
|
||||||
|
margin: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_overrides: {
|
||||||
|
disabled: {
|
||||||
|
root: {
|
||||||
|
style: {
|
||||||
|
color: 'bodyTextDisabled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_precedence: ['disabled']
|
||||||
|
};
|
||||||
|
|
||||||
|
augmentPlatformTheme({
|
||||||
|
settings: {
|
||||||
|
RNFText: textSettings
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { ITextComponent, ITextRenderData, ITextProps } from './Text.types';
|
||||||
|
import { compose } from '@uifabric/foundation-compose';
|
||||||
|
import { textTokenKeys, foregroundColorKeys, processTextTokens, processForegroundTokens } from '../tokens';
|
||||||
|
import { mergeSettings } from '@uifabric/theme-settings';
|
||||||
|
import { loadTextSettings } from './Text.settings';
|
||||||
|
|
||||||
|
loadTextSettings();
|
||||||
|
|
||||||
|
export const Text = compose<ITextComponent>({
|
||||||
|
className: 'RNFText',
|
||||||
|
tokenProcessors: [
|
||||||
|
{
|
||||||
|
keyProps: (textTokenKeys as (keyof ITextProps)[]).concat(foregroundColorKeys),
|
||||||
|
processor: (keyProps: ITextProps, data: ITextRenderData) => {
|
||||||
|
const toMerge = { root: {} };
|
||||||
|
processTextTokens(keyProps, toMerge.root);
|
||||||
|
processForegroundTokens(keyProps, toMerge.root);
|
||||||
|
return mergeSettings(data.slotProps, toMerge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
slots: {
|
||||||
|
root: 'div'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Text;
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { IComponent, IRenderData } from '@uifabric/foundation-compose';
|
||||||
|
import { IComponentSettings, IStyleProp } from '@uifabric/theme-settings';
|
||||||
|
import { ITextTokens, IForegroundColorTokens } from '../tokens';
|
||||||
|
import { ICSSStyle } from '../htmlTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properties for fabric native text field, these extend the default props for text
|
||||||
|
*/
|
||||||
|
export interface ITextProps extends ITextTokens, IForegroundColorTokens {
|
||||||
|
disabled?: boolean;
|
||||||
|
children?: string;
|
||||||
|
style?: IStyleProp<ICSSStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITextSlotProps {
|
||||||
|
root: ITextProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ITextSettings = IComponentSettings<ITextSlotProps>;
|
||||||
|
export type ITextComponent = IComponent<ITextProps, ITextSettings>;
|
||||||
|
export type ITextRenderData = IRenderData<ITextProps, ITextSettings>;
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './Text.types';
|
||||||
|
export * from './Text';
|
|
@ -0,0 +1,8 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { IStyleProp } from '@uifabric/theme-settings';
|
||||||
|
|
||||||
|
export type ICSSStyle = React.CSSProperties;
|
||||||
|
export type IDivProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & { style?: IStyleProp<ICSSStyle> };
|
||||||
|
export type IImageProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLImageElement>, HTMLImageElement> & {
|
||||||
|
style?: IStyleProp<ICSSStyle>;
|
||||||
|
};
|
|
@ -0,0 +1,5 @@
|
||||||
|
export * from './Button/index';
|
||||||
|
export * from './Pressable/index';
|
||||||
|
export * from './Stack/index';
|
||||||
|
export * from './Text/index';
|
||||||
|
export * from './tokens/index';
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { processTokens } from './TokenHelpers';
|
||||||
|
|
||||||
|
export interface IBorderTokens {
|
||||||
|
borderColor?: string;
|
||||||
|
borderWidth?: number | string;
|
||||||
|
borderRadius?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const borderKeys: (keyof IBorderTokens)[] = ['borderColor', 'borderRadius', 'borderWidth'];
|
||||||
|
|
||||||
|
export function processBorderTokens(tokens: IBorderTokens, ...targetProps: object[]): void {
|
||||||
|
processTokens(tokens, borderKeys, ...targetProps);
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { processTokens } from './TokenHelpers';
|
||||||
|
|
||||||
|
export interface IForegroundColorTokens {
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const foregroundColorKeys: (keyof IForegroundColorTokens)[] = ['color'];
|
||||||
|
|
||||||
|
export function processForegroundTokens(tokens: IForegroundColorTokens, ...targetProps: object[]): void {
|
||||||
|
processTokens(tokens, foregroundColorKeys, ...targetProps);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBackgroundColorTokens {
|
||||||
|
backgroundColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const backgroundColorKeys: (keyof IBackgroundColorTokens)[] = ['backgroundColor'];
|
||||||
|
|
||||||
|
export function processBackgroundTokens(tokens: IBackgroundColorTokens, ...targetProps: object[]): void {
|
||||||
|
processTokens(tokens, backgroundColorKeys, ...targetProps);
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { ICSSStyle } from '../htmlTypes';
|
||||||
|
import { processTokens } from './TokenHelpers';
|
||||||
|
|
||||||
|
export interface ITextTokens {
|
||||||
|
fontFamily?: ICSSStyle['fontFamily'] | string;
|
||||||
|
fontSize?: ICSSStyle['fontSize'] | string;
|
||||||
|
fontWeight?: ICSSStyle['fontWeight'] | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const textTokenKeys: (keyof ITextTokens)[] = ['fontFamily', 'fontSize', 'fontWeight'];
|
||||||
|
|
||||||
|
export function processTextTokens(tokens: ITextTokens, ...targetProps: object[]): void {
|
||||||
|
processTokens(tokens, textTokenKeys, ...targetProps);
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { IStyleProp } from '@uifabric/theme-settings';
|
||||||
|
|
||||||
|
export function extractAndReduce<TObj extends object>(target: TObj, keys: (keyof TObj)[]): object {
|
||||||
|
const result: TObj = {} as TObj;
|
||||||
|
keys.forEach((key: keyof TObj) => {
|
||||||
|
if (target.hasOwnProperty(key)) {
|
||||||
|
result[key] = target[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IWithStyleProp<T> = T & { style?: IStyleProp<object> };
|
||||||
|
|
||||||
|
export function processTokens<TTokens extends object, TProps extends object>(
|
||||||
|
tokens: TTokens,
|
||||||
|
keys: (keyof TTokens)[],
|
||||||
|
...targetProps: TProps[]
|
||||||
|
): void {
|
||||||
|
const style = extractAndReduce(tokens, keys);
|
||||||
|
targetProps.forEach((props: IWithStyleProp<TProps>) => {
|
||||||
|
props.style = props.style ? [props.style, style] : style;
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './BorderTokens';
|
||||||
|
export * from './ColorTokens';
|
||||||
|
export * from './TextTokens';
|
||||||
|
export * from './TokenHelpers';
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "lib"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
src
|
||||||
|
node_modules
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.editorconfig
|
||||||
|
config.js
|
||||||
|
jest.config.js
|
||||||
|
tslint.json
|
||||||
|
tsconfig.json
|
||||||
|
jsconfig.json
|
||||||
|
webpack.config.js
|
||||||
|
webpack.serve.config.js
|
||||||
|
*.build.log
|
|
@ -0,0 +1 @@
|
||||||
|
module.exports = require('../../scripts/jest.config');
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "@uifabric/foundation-composable",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "Composable component building blocks",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/microsoft/ui-fabric-react-native"
|
||||||
|
},
|
||||||
|
"main": "lib/index.js",
|
||||||
|
"typings": "lib/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "tsc -w --preserveWatchOutput",
|
||||||
|
"test": "jest",
|
||||||
|
"start-test": "jest --watch"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/es6-collections": "^0.5.29",
|
||||||
|
"@types/es6-promise": "0.0.32",
|
||||||
|
"@types/node": "^10.3.5",
|
||||||
|
"@types/jest": "^19.2.2",
|
||||||
|
"react": "16.8.3"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,120 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
ISlotTypes,
|
||||||
|
IComposable,
|
||||||
|
IProcessResult,
|
||||||
|
IGenericProps,
|
||||||
|
IResolvedSlot,
|
||||||
|
IResolvedSlots,
|
||||||
|
IPropFilter,
|
||||||
|
ISlotWithFilter,
|
||||||
|
ISlotType,
|
||||||
|
INativeSlotType,
|
||||||
|
IWithComposable
|
||||||
|
} from './Composable.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a component tree and return a resolved slot for it. This should be used as the root call for processing
|
||||||
|
* such that the recursion handles automatically
|
||||||
|
*
|
||||||
|
* @param composable - component to process the hierarchy for
|
||||||
|
* @param props - input props to pass into the component hierarchy
|
||||||
|
*/
|
||||||
|
export function useProcessComposableTree(composable: IComposable, props: IGenericProps, theme: object): IResolvedSlot {
|
||||||
|
const info: IProcessResult = composable.useProcessProps(props, theme);
|
||||||
|
return {
|
||||||
|
...info,
|
||||||
|
slots: useSlotProcessing(composable, info, theme),
|
||||||
|
composable: composable
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the slots on a composable, passing in props targeted at each entry and then creating the resolved slot collection
|
||||||
|
* that is ready to use in a render function
|
||||||
|
*
|
||||||
|
* @param composable - composable component to process the slots for
|
||||||
|
* @param info - slot info object which will be combined with resolved props on return
|
||||||
|
* @param slotProps - props to pass into each slot
|
||||||
|
*/
|
||||||
|
export function useSlotProcessing(composable: IComposable, info: IProcessResult, theme: object): IResolvedSlots | undefined {
|
||||||
|
const slotProps = info.slotProps || {};
|
||||||
|
const componentSlots = composable.slots;
|
||||||
|
if (componentSlots) {
|
||||||
|
const slotResults = {};
|
||||||
|
for (const key in componentSlots) {
|
||||||
|
if (componentSlots[key]) {
|
||||||
|
slotResults[key] = useProcessComposableTree(componentSlots[key], slotProps[key] || {}, theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slotResults;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a slot according to the values stored in the slot info object
|
||||||
|
*
|
||||||
|
* @param slot - slot to perform standard rendering for
|
||||||
|
* @param props - props for that slot, same pattern as for React.createElement
|
||||||
|
* @param children - standard children values, as appropriate to pass to React.createElement
|
||||||
|
*/
|
||||||
|
export function renderSlot(slot: IResolvedSlot, ...children: React.ReactNode[]): JSX.Element | null {
|
||||||
|
return slot.composable ? slot.composable.render(slot, ...children) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process function for standard components
|
||||||
|
*
|
||||||
|
* @param props - props to put into the right format for return
|
||||||
|
* @param _theme - theme, unused for this purpose
|
||||||
|
* @param filter - optional filter function
|
||||||
|
*/
|
||||||
|
const _stockProcessor = (props: IGenericProps, _theme: object, filter?: IPropFilter) => {
|
||||||
|
return {
|
||||||
|
props: filter ? filter(props) : props
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a non-composable component type or function and wrap it as a composable component
|
||||||
|
*
|
||||||
|
* @param component - type of component to wrap, either a standard react type or a function that takes props and children
|
||||||
|
*/
|
||||||
|
export function wrapStockComponent(component: INativeSlotType, filter?: IPropFilter): IComposable {
|
||||||
|
return {
|
||||||
|
useProcessProps: filter
|
||||||
|
? (props: IGenericProps, theme: object) => {
|
||||||
|
return _stockProcessor(props, theme, filter);
|
||||||
|
}
|
||||||
|
: _stockProcessor,
|
||||||
|
render: (slotInfo: IProcessResult, ...children: React.ReactNode[]) => {
|
||||||
|
return React.createElement(component, slotInfo.props, ...children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* turn a set of slot types into a set of IComposable interfaces
|
||||||
|
*
|
||||||
|
* @param slots - set of slot types to either wrap in a stock component or embed directly
|
||||||
|
*/
|
||||||
|
export function wrapSlots(slots: ISlotTypes): { [key: string]: IComposable } {
|
||||||
|
const result = {};
|
||||||
|
for (const key in slots) {
|
||||||
|
if (slots[key]) {
|
||||||
|
const slot = slots[key];
|
||||||
|
const isObject = (slot as ISlotWithFilter).slotType;
|
||||||
|
const slotType: ISlotType | undefined = isObject ? (slot as ISlotWithFilter).slotType : (slot as ISlotType);
|
||||||
|
const filter = isObject ? (slot as ISlotWithFilter).filter : undefined;
|
||||||
|
if (slot) {
|
||||||
|
if (typeof slotType !== 'string' && (slotType as IWithComposable).__composable) {
|
||||||
|
result[key] = (slotType as IWithComposable).__composable;
|
||||||
|
} else {
|
||||||
|
result[key] = wrapStockComponent(slotType as INativeSlotType, filter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
|
@ -0,0 +1,92 @@
|
||||||
|
// just a generic object with children specified as props
|
||||||
|
export interface IGenericProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* this is the result of the process call. Note that any additional information returned here
|
||||||
|
* will flow through the system
|
||||||
|
*/
|
||||||
|
export type IProcessResult<TProps extends object = IGenericProps, TSlotProps = ISlotProps, TAdditional = object> = {
|
||||||
|
props?: TProps;
|
||||||
|
slotProps?: TSlotProps;
|
||||||
|
} & TAdditional;
|
||||||
|
|
||||||
|
export interface IResolvedSlotData {
|
||||||
|
composable?: IComposable;
|
||||||
|
slots?: IResolvedSlots;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IAsResolved<TBase> = TBase & IResolvedSlotData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the process results, augmented with cthe composable element itself and optional slots,
|
||||||
|
* ready to render
|
||||||
|
*/
|
||||||
|
export type IResolvedSlot<
|
||||||
|
TProps extends object = IGenericProps,
|
||||||
|
TSlotProps = ISlotProps,
|
||||||
|
TAdditional extends object = object
|
||||||
|
> = IAsResolved<IProcessResult<TProps, TSlotProps, TAdditional>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a collection of resolved slots
|
||||||
|
*/
|
||||||
|
export interface IResolvedSlots {
|
||||||
|
[key: string]: IResolvedSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* props to pass to the sub-components, keys should match slots
|
||||||
|
*/
|
||||||
|
export interface ISlotProps {
|
||||||
|
root: IGenericProps;
|
||||||
|
[key: string]: IGenericProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern for a composable component
|
||||||
|
*/
|
||||||
|
export interface IComposable {
|
||||||
|
useProcessProps: (props: IGenericProps, theme: object) => IProcessResult;
|
||||||
|
render: (propInfo: IProcessResult, ...children: React.ReactNode[]) => JSX.Element | null;
|
||||||
|
slots?: { [key: string]: IComposable };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach a composable component to an object in a standard manner
|
||||||
|
*/
|
||||||
|
export type IWithComposable<T extends object = object> = T & {
|
||||||
|
__composable: IComposable;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generally a slot can be defined as a standard input to createElement or as an object with a __composable value
|
||||||
|
* set on it
|
||||||
|
*/
|
||||||
|
/* tslint:disable-next-line no-any */
|
||||||
|
export type INativeSlotType = React.ElementType<any> | string;
|
||||||
|
export type ISlotType = INativeSlotType | IWithComposable<object>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional function to filter the properties that will be passed to the component. If no props are to be
|
||||||
|
* removed it should return the same object. Otherwise it should return a new object with props filtered
|
||||||
|
*/
|
||||||
|
export type IPropFilter = (props: object) => object;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In the case where a filter needs to be applied to props the slot can be set to an object which contains the slotType
|
||||||
|
* and filter function reference
|
||||||
|
*/
|
||||||
|
export interface ISlotWithFilter {
|
||||||
|
slotType?: ISlotType;
|
||||||
|
filter?: IPropFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The collection of slot types that should be defined on the definition of a component
|
||||||
|
*/
|
||||||
|
export type ISlotTypes = {
|
||||||
|
root: ISlotType | ISlotWithFilter;
|
||||||
|
[key: string]: ISlotType | ISlotWithFilter;
|
||||||
|
};
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './Composable.types';
|
||||||
|
export * from './Composable';
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "lib"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
src
|
||||||
|
node_modules
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.editorconfig
|
||||||
|
config.js
|
||||||
|
jest.config.js
|
||||||
|
tslint.json
|
||||||
|
tsconfig.json
|
||||||
|
jsconfig.json
|
||||||
|
webpack.config.js
|
||||||
|
webpack.serve.config.js
|
||||||
|
*.build.log
|
|
@ -0,0 +1 @@
|
||||||
|
module.exports = require('../../scripts/jest.config');
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "@uifabric/foundation-compose",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "Compose infrastructure",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/microsoft/ui-fabric-react-native"
|
||||||
|
},
|
||||||
|
"main": "lib/index.js",
|
||||||
|
"typings": "lib/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "tsc -w --preserveWatchOutput",
|
||||||
|
"test": "jest",
|
||||||
|
"start-test": "jest --watch"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@uifabric/foundation-composable": "0.1.1",
|
||||||
|
"@uifabric/theming-react-native": "0.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/es6-collections": "^0.5.29",
|
||||||
|
"@types/es6-promise": "0.0.32",
|
||||||
|
"@types/node": "^10.3.5",
|
||||||
|
"@types/jest": "^19.2.2"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { IComponent, IRenderData } from './Component.types';
|
||||||
|
import { getThemeSettings, finalizeSettings, INativeTheme } from '@uifabric/theming-react-native';
|
||||||
|
import {
|
||||||
|
IProcessResult,
|
||||||
|
IResolvedSlot,
|
||||||
|
IComposable,
|
||||||
|
wrapSlots,
|
||||||
|
renderSlot,
|
||||||
|
IGenericProps,
|
||||||
|
ISlotProps
|
||||||
|
} from '@uifabric/foundation-composable';
|
||||||
|
import { mergeSettings } from '@uifabric/theme-settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cache for the given component from the theme, creating it if necessary
|
||||||
|
*
|
||||||
|
* @param component - component to get the cache for, the component object itself will store the unique symbol for its lookups
|
||||||
|
* @param theme - theme where the cache will be stored
|
||||||
|
*/
|
||||||
|
function _getComponentCache(component: IComponent, theme: INativeTheme): { [key: string]: ISlotProps } {
|
||||||
|
const cacheKey = component.tokenCacheKey!;
|
||||||
|
theme[cacheKey] = theme[cacheKey] || {};
|
||||||
|
return theme[cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new render data object with props and theme. State is an empty object unless this routine is
|
||||||
|
* overridden. At that point a component can store what they wish in state.
|
||||||
|
*
|
||||||
|
* @param props - input props for the component, these will be shallow copied to allow mutating the object
|
||||||
|
* @param theme - theme to set into the render data
|
||||||
|
*/
|
||||||
|
export function standardPrepareRenderData(props: IGenericProps, theme: INativeTheme): IRenderData {
|
||||||
|
return {
|
||||||
|
props: { ...props },
|
||||||
|
theme,
|
||||||
|
state: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the entry specified by name from the theme, uses the props as the mask for overrides. So if
|
||||||
|
* there is an override called 'disabled' this will be set if a prop called disabled is truthy.
|
||||||
|
*
|
||||||
|
* @param name - name of the theme entry to try to look up
|
||||||
|
* @param renderData - render data being prepared
|
||||||
|
*/
|
||||||
|
export function standardThemeSettings(name: string, renderData: IRenderData, overrides?: object): IRenderData {
|
||||||
|
const { settings, styleKey } = getThemeSettings(renderData.theme, name, overrides || renderData.props);
|
||||||
|
return {
|
||||||
|
...renderData,
|
||||||
|
slotProps: settings,
|
||||||
|
settingsKey: styleKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At the point the token processor runs only the theme data has been loaded. This may include values for tokens which
|
||||||
|
* will be present in the root entry of the slot props. This will extract the token keys from these rootProps first, then
|
||||||
|
* from the userProps, giving precedence to the user props.
|
||||||
|
*
|
||||||
|
* The values of the keys that are specified but different in user props are used to build a cache key, applied on top of the
|
||||||
|
* theming key for lookups.
|
||||||
|
*
|
||||||
|
* @param userProps - user props coming into the control
|
||||||
|
* @param rootSlotProps - root slot props which provide baseline values
|
||||||
|
* @param keys - keys to extract from the set
|
||||||
|
*/
|
||||||
|
function _collectKeys(userProps: object, rootSlotProps: object, keys: string[]): { collected: object | undefined; delta: string[] } {
|
||||||
|
const collected = {};
|
||||||
|
const delta: string[] = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
if (rootSlotProps.hasOwnProperty(key)) {
|
||||||
|
collected[key] = rootSlotProps[key];
|
||||||
|
}
|
||||||
|
if (userProps.hasOwnProperty(key)) {
|
||||||
|
const userProp = userProps[key];
|
||||||
|
if (userProp !== collected[key]) {
|
||||||
|
delta.push(String(userProp));
|
||||||
|
}
|
||||||
|
collected[key] = userProp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { collected, delta };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the applicable token processors, caching the result so that other control instances with the same props combinations
|
||||||
|
* can re-use the results
|
||||||
|
*
|
||||||
|
* @param component - component options that holds the token processors
|
||||||
|
* @param renderData - render data to update with the results
|
||||||
|
*/
|
||||||
|
function _processTokens(component: IComponent, renderData: IRenderData): IRenderData {
|
||||||
|
if (component.tokenProcessors && component.tokenKeys) {
|
||||||
|
const cache = _getComponentCache(component, renderData.theme);
|
||||||
|
const rootProps = (renderData.slotProps && renderData.slotProps.root) || {};
|
||||||
|
const { collected, delta } = _collectKeys(renderData.props, rootProps, component.tokenKeys);
|
||||||
|
let cacheKey = renderData.settingsKey || 'none';
|
||||||
|
if (delta.length > 0) {
|
||||||
|
cacheKey = cacheKey.concat('-', delta.join('-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cache[cacheKey]) {
|
||||||
|
renderData.slotProps = cache[cacheKey];
|
||||||
|
} else {
|
||||||
|
for (const entry of component.tokenProcessors) {
|
||||||
|
renderData.slotProps = entry.processor(collected || {}, renderData);
|
||||||
|
}
|
||||||
|
if (renderData.slotProps) {
|
||||||
|
renderData.slotProps = finalizeSettings(renderData.theme, renderData.slotProps);
|
||||||
|
}
|
||||||
|
cache[cacheKey] = renderData.slotProps as ISlotProps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return renderData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate all the keys together from the token processors and de-dup them
|
||||||
|
*
|
||||||
|
* @param component - component to aggregate key arrays for
|
||||||
|
*/
|
||||||
|
export function mergeTokenKeys(component: IComponent): string[] {
|
||||||
|
const collector: { [key: string]: boolean } = {};
|
||||||
|
let keys: string[] = [];
|
||||||
|
if (component.tokenProcessors) {
|
||||||
|
for (const processor of component.tokenProcessors) {
|
||||||
|
if (processor.keyProps && processor.keyProps.length > 0) {
|
||||||
|
for (const key of processor.keyProps) {
|
||||||
|
collector[key] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys = Object.keys(collector);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This routine will merge props into slotProps.root, then finalize the result ensuring any new style keys can be authored
|
||||||
|
* against the theme names
|
||||||
|
*
|
||||||
|
* @param renderData - data being prepared for render.
|
||||||
|
*/
|
||||||
|
export function standardFinalizer(renderData: IRenderData): IRenderData {
|
||||||
|
renderData.slotProps = finalizeSettings(renderData.theme, mergeSettings(renderData.slotProps!, { root: renderData.props }));
|
||||||
|
return renderData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function standardUsePrepareState(renderData: IRenderData): IRenderData {
|
||||||
|
return renderData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the input props and get all the properties ready to render
|
||||||
|
*
|
||||||
|
* @param component - component to process the props for
|
||||||
|
* @param componentProps - input props passed in
|
||||||
|
* @param theme - active theme for this component
|
||||||
|
*/
|
||||||
|
export function useProcessComponent<TComponent extends IComponent>(
|
||||||
|
component: TComponent,
|
||||||
|
userProps: IGenericProps,
|
||||||
|
theme: object
|
||||||
|
): IProcessResult {
|
||||||
|
// set up the initial render data and call any hooks for the component
|
||||||
|
let renderData = standardPrepareRenderData(userProps, theme as INativeTheme);
|
||||||
|
renderData = component.usePrepareState!(renderData);
|
||||||
|
|
||||||
|
// query settings from the theme
|
||||||
|
renderData = component.themeSettings
|
||||||
|
? component.themeSettings(component.className, renderData)
|
||||||
|
: standardThemeSettings(component.className, renderData);
|
||||||
|
|
||||||
|
// process tokens if any are specified
|
||||||
|
renderData = _processTokens(component, renderData);
|
||||||
|
|
||||||
|
// finally run any finalizers on the props
|
||||||
|
renderData = component.finalizer ? component.finalizer(renderData) : standardFinalizer(renderData);
|
||||||
|
|
||||||
|
return renderData as IProcessResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Either call the view function if one exists, or just do a simple renderSlot call on the root slot
|
||||||
|
*
|
||||||
|
* @param component - component options to use for rendering
|
||||||
|
* @param result - the resolved slot data for this component. This will include the IRenderData
|
||||||
|
*/
|
||||||
|
export function renderComponent<TComponent extends IComponent>(
|
||||||
|
component: TComponent,
|
||||||
|
result: IResolvedSlot,
|
||||||
|
...children: React.ReactNode[]
|
||||||
|
): JSX.Element | null {
|
||||||
|
/* tslint:disable-next-line no-any */
|
||||||
|
return component.view
|
||||||
|
? component.view(result as any, ...children)
|
||||||
|
: (result.slots && result.slots.root && renderSlot(result.slots.root, ...children)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a component options object and create an IComposable wrapper for it
|
||||||
|
*
|
||||||
|
* @param component - component options for this component
|
||||||
|
*/
|
||||||
|
export function wrapComponent<TComponent extends IComponent>(component: TComponent): IComposable {
|
||||||
|
return {
|
||||||
|
useProcessProps: (props: IGenericProps, theme: object) => {
|
||||||
|
return useProcessComponent(component, props, theme);
|
||||||
|
},
|
||||||
|
render: (result: IResolvedSlot, ...children: React.ReactNode[]) => {
|
||||||
|
return renderComponent(component, result, ...children);
|
||||||
|
},
|
||||||
|
slots: component.slots ? wrapSlots(component.slots) : undefined
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { INativeTheme } from '@uifabric/theming-react-native';
|
||||||
|
import { ISlotTypes, IResolvedSlotData, IComposable } from '@uifabric/foundation-composable';
|
||||||
|
import { IComponentSettings } from '@uifabric/theme-settings';
|
||||||
|
|
||||||
|
/* tslint:disable no-any */
|
||||||
|
|
||||||
|
export interface IRenderData<
|
||||||
|
TProps extends object = object,
|
||||||
|
TSlotProps extends IComponentSettings = IComponentSettings,
|
||||||
|
TState extends object = any
|
||||||
|
> {
|
||||||
|
props: TProps;
|
||||||
|
theme: INativeTheme;
|
||||||
|
state: TState;
|
||||||
|
slotProps?: TSlotProps;
|
||||||
|
settingsKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IResolvedData<TProps extends object, TSlotProps extends IComponentSettings, TState extends object = any> = IRenderData<
|
||||||
|
TProps,
|
||||||
|
TSlotProps,
|
||||||
|
TState
|
||||||
|
> &
|
||||||
|
IResolvedSlotData;
|
||||||
|
|
||||||
|
export interface ITokenProcessor<TProps extends object, TSlotProps extends IComponentSettings, TState extends object = any> {
|
||||||
|
/**
|
||||||
|
* processing function that takes the information in the render data and returns a potentially updated slot props to be set into the
|
||||||
|
* render data (and potentially cached)
|
||||||
|
*/
|
||||||
|
processor: (keyProps: TProps, renderData: IRenderData<TProps, TSlotProps, TState>) => TSlotProps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* properties used as keys for caching the results of the styling function. If the keys are unchanged from the last time it ran
|
||||||
|
* the function will not be called again. These should be props that are
|
||||||
|
*/
|
||||||
|
keyProps: (keyof TProps)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* finalizer function which does final processing on slotProps to prepare for render
|
||||||
|
*/
|
||||||
|
export type IFinalizer<TProps extends object, TSlotProps extends IComponentSettings, TState extends object = object> = (
|
||||||
|
renderData: IRenderData<TProps, TSlotProps, TState>
|
||||||
|
) => IRenderData<TProps, TSlotProps, TState>;
|
||||||
|
|
||||||
|
export interface IComponent<
|
||||||
|
TProps extends object = object,
|
||||||
|
TSlotProps extends IComponentSettings = IComponentSettings,
|
||||||
|
TCustomizeableProps extends TProps = TProps,
|
||||||
|
TState extends object = any,
|
||||||
|
TStatics extends object = object
|
||||||
|
> {
|
||||||
|
className: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* used for type extraction, this will become the props interface for the component, the one that is used when authoring
|
||||||
|
* against the component using JSX
|
||||||
|
*/
|
||||||
|
propsType?: TProps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This routine should return a new render data object with the props, userProps and theme specified. While a default implementation
|
||||||
|
* will be provided by compose, if the component uses hooks this routine should be implemented and they should be called here.
|
||||||
|
*
|
||||||
|
* Part of this routine should include setting the theme on props for use with styled-component style processing
|
||||||
|
*/
|
||||||
|
usePrepareState?: (
|
||||||
|
renderData: IRenderData<TCustomizeableProps, TSlotProps, TState>
|
||||||
|
) => IRenderData<TCustomizeableProps, TSlotProps, TState>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the settings for this component from the theme and put them into slotProps. The default processing will retrieve the
|
||||||
|
* settings by class name, using the props as the override lookup object.
|
||||||
|
*/
|
||||||
|
themeSettings?: (
|
||||||
|
name: string,
|
||||||
|
renderData: IRenderData<TCustomizeableProps, TSlotProps, TState>
|
||||||
|
) => IRenderData<TCustomizeableProps, TSlotProps, TState>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An array of style processing functions, all entries with cacheableMask set will be applied first, followed by entries without
|
||||||
|
* that flag. Results of token processing will be cached in the theme
|
||||||
|
*/
|
||||||
|
tokenProcessors?: ITokenProcessor<TCustomizeableProps, TSlotProps, TState>[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the consolidated set of token keys for all the token processors, used for extracting properties and caching the results
|
||||||
|
*/
|
||||||
|
tokenKeys?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The finalizer function does final preparation for render. The default one will push all props to the root entry of the slot props
|
||||||
|
*/
|
||||||
|
finalizer?: IFinalizer<TCustomizeableProps, TSlotProps, TState>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View function, called to do the final render
|
||||||
|
*/
|
||||||
|
view?: (renderData: IResolvedData<TCustomizeableProps, TSlotProps, TState>, ...children: React.ReactNode[]) => JSX.Element | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* statics to be added to the component
|
||||||
|
*/
|
||||||
|
statics?: TStatics;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional slots to enable compound controls
|
||||||
|
*/
|
||||||
|
slots?: ISlotTypes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symbol used to uniquely identify the theme cache for this component, will be set at creation time
|
||||||
|
*/
|
||||||
|
tokenCacheKey?: symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to extract the base props type from the component
|
||||||
|
*/
|
||||||
|
export type IComponentProps<TComponent extends IComponent> = NonNullable<TComponent['propsType']>;
|
||||||
|
|
||||||
|
export type IPropsWithChildren<TProps extends object> = TProps & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type IComponentCustomizations<TComponent extends IComponent> = {
|
||||||
|
__options: TComponent;
|
||||||
|
__composable: IComposable;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IReactComponentType<TComponent extends IComponent> = React.FunctionComponent<IComponentProps<TComponent>> &
|
||||||
|
IComponentCustomizations<TComponent>;
|
||||||
|
|
||||||
|
export type IComponentReturnType<TComponent extends IComponent> = IReactComponentType<TComponent> & TComponent['statics'];
|
|
@ -0,0 +1,62 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { IComponent, IComponentCustomizations, IComponentReturnType, IReactComponentType, IComponentProps } from './Component.types';
|
||||||
|
import { ISlotTypes, useProcessComposableTree, renderSlot, IGenericProps } from '@uifabric/foundation-composable';
|
||||||
|
import { wrapComponent, mergeTokenKeys, standardUsePrepareState } from './Component';
|
||||||
|
import { ThemeContext, getTheme } from '@uifabric/theming-react-native';
|
||||||
|
|
||||||
|
function getComponentOptions<TComponent extends IComponent>(inputComponent: TComponent, base?: React.ReactElement<object>): TComponent {
|
||||||
|
const baseComposable = (base && ((base as unknown) as IComponentCustomizations<TComponent>).__options) || undefined;
|
||||||
|
const baseRoot = (!baseComposable && base) || undefined;
|
||||||
|
if (baseComposable || baseRoot) {
|
||||||
|
const slots: ISlotTypes = {
|
||||||
|
...((baseComposable && baseComposable.slots) || {}),
|
||||||
|
...inputComponent.slots
|
||||||
|
} as ISlotTypes;
|
||||||
|
if (baseRoot) {
|
||||||
|
/* tslint:disable-next-line no-any */
|
||||||
|
slots.root = baseRoot as any;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...baseComposable,
|
||||||
|
...inputComponent,
|
||||||
|
slots
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return inputComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles a higher order component based on the following: styles, theme, view, and slots.
|
||||||
|
* Imposes a separation of concern and centralizes styling processing to increase ease of use and robustness
|
||||||
|
* in how components use and apply styling and theming.
|
||||||
|
*
|
||||||
|
* @param component - component definition for the component to be created. See IComponent for more details.
|
||||||
|
*/
|
||||||
|
export function compose<TComponent extends IComponent>(
|
||||||
|
inputComponent: TComponent,
|
||||||
|
base?: React.ReactElement<object>
|
||||||
|
): IComponentReturnType<TComponent> {
|
||||||
|
const options = getComponentOptions(inputComponent, base);
|
||||||
|
options.tokenKeys = mergeTokenKeys(options);
|
||||||
|
options.tokenCacheKey = Symbol(options.className);
|
||||||
|
options.usePrepareState = options.usePrepareState || standardUsePrepareState;
|
||||||
|
const composable = wrapComponent(options);
|
||||||
|
|
||||||
|
const Component: IReactComponentType<TComponent> = (userProps: IComponentProps<TComponent>) => {
|
||||||
|
// get the theme value from the context (or the default theme if it is not set)
|
||||||
|
const theme = React.useContext(ThemeContext) || getTheme();
|
||||||
|
|
||||||
|
// perform prop resolution as specified by the composable pattern
|
||||||
|
const result = useProcessComposableTree(composable, userProps as IGenericProps, theme);
|
||||||
|
|
||||||
|
// now use the slot renderer to call the view function
|
||||||
|
return renderSlot(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
// set the displayName and merge in statics
|
||||||
|
Component.displayName = options.className;
|
||||||
|
Component.__options = options;
|
||||||
|
Component.__composable = composable;
|
||||||
|
Object.assign(Component, options.statics);
|
||||||
|
return Component;
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './Component.types';
|
||||||
|
export * from './Component';
|
||||||
|
export * from './compose';
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "lib"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
src
|
||||||
|
node_modules
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.editorconfig
|
||||||
|
config.js
|
||||||
|
jest.config.js
|
||||||
|
tslint.json
|
||||||
|
tsconfig.json
|
||||||
|
jsconfig.json
|
||||||
|
webpack.config.js
|
||||||
|
webpack.serve.config.js
|
||||||
|
*.build.log
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Immutable Merge package
|
||||||
|
|
||||||
|
This package provides a relatively concise routine to handle merging multiple objects together with the following characteristics:
|
||||||
|
|
||||||
|
- No modifications will be made to any object
|
||||||
|
- The resulting object will have the minimum number of updates. If only one value is updated three levels deep, only that value and the chain of containing objects will be recreated.
|
||||||
|
- Empty objects or undefined objects will be ignored and not cause a new branch to be created.
|
||||||
|
- Recursion is controllable in a variety of ways
|
||||||
|
|
||||||
|
## IMergeOptions
|
||||||
|
|
||||||
|
| Property | Description |
|
||||||
|
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `depth?: number` | Depth of recursion. Positive numbers will recurse that number of times, 0 will not recurse, a negative number will recurse infinitely. Unspecified is treated as 0. |
|
||||||
|
| `processSingles?: boolean` | If true this will run through the tree even if there is only one viable branch. This is used to allow handlers to optionally update branches of the tree. This allows the routine to be used for things like processing all style objects in a complex tree, optionally changing them based on some logic, and returning a minimally updated tree. |
|
||||||
|
| `recurse?: { [key] : boolean | handler }` | This allows overriding the normal handling based on the name of encountered keys. A value of true means that the routine will always recurse if that value is encountered. Specifying a handler function will cause that handler to be run when that key is encountered. |
|
||||||
|
|
||||||
|
## IMergeRecursionHandler
|
||||||
|
|
||||||
|
This is the signature for a handler function that can handle a named branch of the tree.
|
||||||
|
|
||||||
|
export type IMergeRecursionHandler = (
|
||||||
|
key: string,
|
||||||
|
options: IMergeOptions,
|
||||||
|
...objs: (object | undefined)[]) => object | undefined;
|
||||||
|
|
||||||
|
The array of objects or undefined values (internally treated as anything falsy) should be processed, returning a single object or undefined.
|
||||||
|
|
||||||
|
The key and options parameters are provided as conveniences in the case that a single handler needs to differentiate different branches or know how deep it is in the tree. In many cases these can be ignored.
|
||||||
|
|
||||||
|
## immutableMerge
|
||||||
|
|
||||||
|
export function immutableMerge(
|
||||||
|
options: IMergeOptions, ...objs: (object | undefined)[]
|
||||||
|
): object | undefined {
|
||||||
|
|
||||||
|
The routine works as described above with one notable behavior. Unlike `Object.assign()`, undefined values will cause the key to be deleted. Otherwise there is no easy way to delete keys using an immutable style pattern. So merging works as follows:
|
||||||
|
|
||||||
|
const obj1 = {
|
||||||
|
foo: 'hello',
|
||||||
|
bar: 'world'
|
||||||
|
};
|
||||||
|
|
||||||
|
const obj2 = {
|
||||||
|
bar: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const objIM = immutableMerge({}, obj1, obj2);
|
||||||
|
// objIM.hasOwnProperty('bar') will return false
|
||||||
|
|
||||||
|
const objAssign = Object.assign({}, obj1, obj2);
|
||||||
|
// objAssign.hasOwnProperty('bar') will return true but objAssign['bar'] will be undefined
|
||||||
|
|
||||||
|
## Things to Explore
|
||||||
|
|
||||||
|
- This should be stress tested and perf tested, because it is such a core routine it needs to be very fast.
|
|
@ -0,0 +1 @@
|
||||||
|
module.exports = require('../../scripts/jest.config');
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "@uifabric/immutable-merge",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "Immutable merge routine",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/microsoft/ui-fabric-react-native"
|
||||||
|
},
|
||||||
|
"main": "lib/index.js",
|
||||||
|
"typings": "lib/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "tsc -w --preserveWatchOutput",
|
||||||
|
"test": "jest",
|
||||||
|
"start-test": "jest --watch"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/es6-collections": "^0.5.29",
|
||||||
|
"@types/es6-promise": "0.0.32",
|
||||||
|
"@types/node": "^10.3.5",
|
||||||
|
"@types/jest": "^19.2.2"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { IMergeOptions, immutableMerge } from './Merge';
|
||||||
|
|
||||||
|
interface IFakeStyle {
|
||||||
|
s1?: string;
|
||||||
|
s2?: number;
|
||||||
|
nm?: INoMerge;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface INoMerge {
|
||||||
|
nm1?: number;
|
||||||
|
nm2?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFakeSettings {
|
||||||
|
root: {
|
||||||
|
p1?: string;
|
||||||
|
p2?: number;
|
||||||
|
nm?: INoMerge;
|
||||||
|
style?: IFakeStyle;
|
||||||
|
};
|
||||||
|
fakeSlot?: {
|
||||||
|
ps1?: string;
|
||||||
|
ps2?: number;
|
||||||
|
style?: IFakeStyle;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sett1: IFakeSettings = {
|
||||||
|
root: {
|
||||||
|
p2: 1,
|
||||||
|
nm: { nm1: 1 },
|
||||||
|
style: { s1: 'foo', s2: 2, nm: { nm1: 1 } }
|
||||||
|
},
|
||||||
|
fakeSlot: {
|
||||||
|
ps2: 2
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sett2: IFakeSettings = {
|
||||||
|
root: {
|
||||||
|
p1: 'sett2',
|
||||||
|
p2: 2,
|
||||||
|
nm: { nm2: 2 }
|
||||||
|
},
|
||||||
|
fakeSlot: {
|
||||||
|
style: { s1: 'sett2' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sett1plus2: IFakeSettings = {
|
||||||
|
root: {
|
||||||
|
p1: 'sett2',
|
||||||
|
p2: 2,
|
||||||
|
nm: { nm2: 2 },
|
||||||
|
style: { s1: 'foo', s2: 2, nm: { nm1: 1 } }
|
||||||
|
},
|
||||||
|
fakeSlot: {
|
||||||
|
ps2: 2,
|
||||||
|
style: { s1: 'sett2' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sett3: IFakeSettings = {
|
||||||
|
root: {
|
||||||
|
p1: 'sett3',
|
||||||
|
style: { s2: 3, nm: { nm2: 2 } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sett1plus3: IFakeSettings = {
|
||||||
|
root: {
|
||||||
|
p1: 'sett3',
|
||||||
|
p2: 1,
|
||||||
|
nm: { nm1: 1 },
|
||||||
|
style: { s1: 'foo', s2: 3, nm: { nm2: 2 } }
|
||||||
|
},
|
||||||
|
fakeSlot: {
|
||||||
|
ps2: 2
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sett1plus2plus3: IFakeSettings = {
|
||||||
|
root: {
|
||||||
|
p1: 'sett3',
|
||||||
|
p2: 2,
|
||||||
|
nm: { nm2: 2 },
|
||||||
|
style: { s1: 'foo', s2: 3, nm: { nm2: 2 } }
|
||||||
|
},
|
||||||
|
fakeSlot: {
|
||||||
|
ps2: 2,
|
||||||
|
style: { s1: 'sett2' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeOptions: IMergeOptions = {
|
||||||
|
depth: 1,
|
||||||
|
recurse: {
|
||||||
|
style: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IDeepObj {
|
||||||
|
a: { b: { c: number } };
|
||||||
|
b: { c: { d: { d: string } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const deep1 = {
|
||||||
|
a: { b: { c: 1 } },
|
||||||
|
b: { c: { d: { d: 'foo' } } },
|
||||||
|
c: { e: 4 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const deep2 = {
|
||||||
|
a: { b1: 3, b: { c: 2 } },
|
||||||
|
b: { c: { d2: 'bar' } },
|
||||||
|
c: { e2: { f: 'baz' } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const deepMerged = {
|
||||||
|
a: { b1: 3, b: { c: 2 } },
|
||||||
|
b: { c: { d: { d: 'foo' }, d2: 'bar' } },
|
||||||
|
c: { e: 4, e2: { f: 'baz' } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const singleToChange = {
|
||||||
|
a: { b: { c: { changeMe: { color: 'blue' } } } },
|
||||||
|
b: { d: { changeMe: { font: 'fixed' } } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const singleWithChanges = {
|
||||||
|
a: { b: { c: { changeMe: { color: 'changed' } } } },
|
||||||
|
b: { d: { changeMe: { font: 'fixed' } } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const _colorKey = 'color';
|
||||||
|
|
||||||
|
const changeMeHandler = (_key: string, _options: IMergeOptions, ...objs: (object | undefined)[]) => {
|
||||||
|
// written always assuming only one entry
|
||||||
|
if (objs.length === 1) {
|
||||||
|
const firstObj = objs[0]!;
|
||||||
|
|
||||||
|
if (firstObj[_colorKey]) {
|
||||||
|
return { ...firstObj, color: 'changed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstObj;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Component settings unit tests', () => {
|
||||||
|
test('merge one', () => {
|
||||||
|
const merged = immutableMerge(mergeOptions, sett1, undefined);
|
||||||
|
expect(merged).toBe(sett1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merge with empty object', () => {
|
||||||
|
const merged = immutableMerge(mergeOptions, sett1, {});
|
||||||
|
expect(merged).toBe(sett1);
|
||||||
|
const merged2 = immutableMerge(mergeOptions, {}, sett2);
|
||||||
|
expect(merged2).toBe(sett2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merge sett1 and sett2', () => {
|
||||||
|
const merged = immutableMerge(mergeOptions, sett1, sett2) as IFakeSettings;
|
||||||
|
expect(merged).toEqual(sett1plus2);
|
||||||
|
expect(merged!.root.style).toBe(sett1.root.style);
|
||||||
|
expect(merged!.fakeSlot!.style).toBe(sett2.fakeSlot!.style);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merge sett1 and sett3', () => {
|
||||||
|
const merged = immutableMerge(mergeOptions, sett1, sett3) as IFakeSettings;
|
||||||
|
expect(merged).toEqual(sett1plus3);
|
||||||
|
expect(merged!.fakeSlot).toBe(sett1.fakeSlot);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merge three', () => {
|
||||||
|
const merged = immutableMerge(mergeOptions, sett1, sett2, sett3);
|
||||||
|
expect(merged).toEqual(sett1plus2plus3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deepMerge', () => {
|
||||||
|
const merged = immutableMerge({ depth: -1 }, deep1, deep2) as IDeepObj;
|
||||||
|
expect(merged).toEqual(deepMerged);
|
||||||
|
expect(merged.b.c.d).toBe(deep1.b.c.d);
|
||||||
|
expect(merged.a.b).not.toBe(deep2.a.b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('singleProcessNoChange', () => {
|
||||||
|
const merged = immutableMerge({ depth: -1, processSingles: true }, singleToChange);
|
||||||
|
expect(merged).toBe(singleToChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single process with change', () => {
|
||||||
|
const merged = immutableMerge({ depth: -1, processSingles: true, recurse: { changeMe: changeMeHandler } }, singleToChange);
|
||||||
|
expect(merged).toEqual(singleWithChanges);
|
||||||
|
expect(merged).not.toBe(singleToChange);
|
||||||
|
/* tslint:disable-next-line no-any */
|
||||||
|
expect((merged as any).b).toBe(singleToChange.b);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,84 @@
|
||||||
|
export interface IMergeOptions {
|
||||||
|
/**
|
||||||
|
* number of times to recurse:
|
||||||
|
* - <0 : infinite
|
||||||
|
* - 0 or undefined : don't recurse
|
||||||
|
* - 1+ : recurse this many levels
|
||||||
|
*/
|
||||||
|
depth?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* if true this will run through the tree even if there is only one viable branch. This is so the handlers can do
|
||||||
|
* processing, only modifying the tree if something is updated
|
||||||
|
*/
|
||||||
|
processSingles?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* map of key values to recurse on, this will override the depth if specified
|
||||||
|
*/
|
||||||
|
recurse?: { [key: string]: boolean | IMergeRecursionHandler };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IMergeRecursionHandler = (key: string, options: IMergeOptions, ...objs: (object | undefined)[]) => object | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This will merge two or more objects together using an immutable style merge pattern. If there is only one object or
|
||||||
|
* if there is only one object with values, that object will be returned, with two or more objects the keys within will
|
||||||
|
* be first merged with Object.assign and then optionally will recurse to merge sub objects as specified by the options.
|
||||||
|
*
|
||||||
|
* Note that this tries hard to not create extra objects, because of this merging an object with an empty object will not
|
||||||
|
* create a new object.
|
||||||
|
*
|
||||||
|
* @param options - options driving behavior of the merge. See IMergeOptions for a description. Some basic combos would
|
||||||
|
* be {} - no recursion, { depth: -1 } - recurse infinitely
|
||||||
|
* @param objs - an array of objects to merge together
|
||||||
|
*/
|
||||||
|
export function immutableMerge(options: IMergeOptions, ...objs: (object | undefined)[]): object | undefined {
|
||||||
|
const setToMerge = objs.filter((value: object | undefined) => {
|
||||||
|
return value && Object.getOwnPropertyNames(value).length > 0;
|
||||||
|
});
|
||||||
|
const processSingle = options.processSingles && setToMerge.length === 1;
|
||||||
|
if (setToMerge.length > 1 || processSingle) {
|
||||||
|
const depth = options.depth || 0;
|
||||||
|
const recurse = options.recurse;
|
||||||
|
const nextOptions = depth || recurse ? { ...options, depth: depth ? depth - 1 : 0 } : undefined;
|
||||||
|
let hasChanged = false;
|
||||||
|
|
||||||
|
// now assign everything to get the normal property precedence (and merge all the keys)
|
||||||
|
const result = Object.assign({}, ...setToMerge);
|
||||||
|
|
||||||
|
// if there is a possibility of needing the recurse, process the keys
|
||||||
|
for (const key in result) {
|
||||||
|
if (result.hasOwnProperty(key)) {
|
||||||
|
const originalVal = result[key];
|
||||||
|
// next options is only set if there is a possibility of recursion
|
||||||
|
if (nextOptions) {
|
||||||
|
// if this key qualifies for recursion and the last value set was an object, try to merge
|
||||||
|
if ((depth || (recurse && recurse[key])) && typeof originalVal === 'object') {
|
||||||
|
const collectedObjects = setToMerge.map((value: object | undefined) => {
|
||||||
|
return typeof value![key] === 'object' ? value![key] : undefined;
|
||||||
|
});
|
||||||
|
const handler = recurse && recurse[key];
|
||||||
|
result[key] =
|
||||||
|
handler && typeof handler === 'function'
|
||||||
|
? handler(key, nextOptions, ...collectedObjects)
|
||||||
|
: immutableMerge(nextOptions, ...collectedObjects);
|
||||||
|
|
||||||
|
// this only matters for the single process case, in that case the only possible change is from a special
|
||||||
|
// handler on recursion, so if it has returned a new object mark that it has changed
|
||||||
|
if (result[key] !== originalVal) {
|
||||||
|
hasChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// delete undefined keys from the object, otherwise there is no easy way to delete keys
|
||||||
|
if (result[key] === undefined) {
|
||||||
|
delete result[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// in the single processing case return the original if nothing changed, otherwise return result
|
||||||
|
return processSingle && !hasChanged ? setToMerge[0] : result;
|
||||||
|
}
|
||||||
|
return setToMerge.length > 0 ? setToMerge[0] : undefined;
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './Merge';
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "lib"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
src
|
||||||
|
node_modules
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.editorconfig
|
||||||
|
config.js
|
||||||
|
jest.config.js
|
||||||
|
tslint.json
|
||||||
|
tsconfig.json
|
||||||
|
jsconfig.json
|
||||||
|
webpack.config.js
|
||||||
|
webpack.serve.config.js
|
||||||
|
*.build.log
|
|
@ -0,0 +1 @@
|
||||||
|
module.exports = require('../../scripts/jest.config');
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "@uifabric/theme-registry",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "Implementation of the theme graph",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/microsoft/ui-fabric-react-native"
|
||||||
|
},
|
||||||
|
"main": "lib/index.js",
|
||||||
|
"typings": "lib/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "tsc -w --preserveWatchOutput",
|
||||||
|
"test": "jest",
|
||||||
|
"start-test": "jest --watch"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/es6-collections": "^0.5.29",
|
||||||
|
"@types/es6-promise": "0.0.32",
|
||||||
|
"@types/node": "^10.3.5",
|
||||||
|
"@types/jest": "^19.2.2",
|
||||||
|
"@uifabric/immutable-merge": "0.1.1"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,276 @@
|
||||||
|
import { createThemeRegistry } from './Registry';
|
||||||
|
import { IThemeRegistry } from './Registry.types';
|
||||||
|
import { immutableMerge } from '@uifabric/immutable-merge';
|
||||||
|
|
||||||
|
interface IFakeStyle {
|
||||||
|
textColor?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
fontFamily?: string;
|
||||||
|
fontSize?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFakeTheme {
|
||||||
|
palette: {
|
||||||
|
bodyBackground?: string;
|
||||||
|
bodyText?: string;
|
||||||
|
};
|
||||||
|
typography: {
|
||||||
|
families?: {
|
||||||
|
primary?: string;
|
||||||
|
monospace?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
spacing: {
|
||||||
|
s2?: string;
|
||||||
|
s1?: string;
|
||||||
|
m?: string;
|
||||||
|
l1?: string;
|
||||||
|
l2?: string;
|
||||||
|
};
|
||||||
|
settings: {
|
||||||
|
[key: string]: {
|
||||||
|
root: IFakeStyle;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const _platformDefaults: IFakeTheme = {
|
||||||
|
palette: {
|
||||||
|
bodyBackground: '#000000',
|
||||||
|
bodyText: '#ffffff'
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
families: {
|
||||||
|
primary: 'Platform Font Primary',
|
||||||
|
monospace: 'Platform Font Monospace'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
spacing: { s2: '4px', s1: '8px', m: '16px', l1: '20px', l2: '32px' },
|
||||||
|
settings: {
|
||||||
|
base: {
|
||||||
|
root: {
|
||||||
|
textColor: 'black',
|
||||||
|
fontFamily: 'Verdana',
|
||||||
|
fontSize: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _ocean: Partial<IFakeTheme> = {
|
||||||
|
settings: {
|
||||||
|
base: {
|
||||||
|
root: {
|
||||||
|
textColor: 'blue',
|
||||||
|
fontFamily: 'Arial'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MyButton: {
|
||||||
|
root: {
|
||||||
|
fontSize: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _platformDefaultsMergedWithOcean: IFakeTheme = {
|
||||||
|
palette: {
|
||||||
|
bodyBackground: '#000000',
|
||||||
|
bodyText: '#ffffff'
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
families: {
|
||||||
|
primary: 'Platform Font Primary',
|
||||||
|
monospace: 'Platform Font Monospace'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
spacing: { s2: '4px', s1: '8px', m: '16px', l1: '20px', l2: '32px' },
|
||||||
|
settings: {
|
||||||
|
base: {
|
||||||
|
root: {
|
||||||
|
textColor: 'blue',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
fontSize: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MyButton: {
|
||||||
|
root: {
|
||||||
|
fontSize: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function processor(parent: IFakeTheme): Partial<IFakeTheme> {
|
||||||
|
return {
|
||||||
|
settings: {
|
||||||
|
base: {
|
||||||
|
root: {
|
||||||
|
textColor: 'light' + (parent.settings.base.root as IFakeStyle).textColor,
|
||||||
|
fontFamily: (parent.settings.base.root as IFakeStyle).fontFamily + ' Light',
|
||||||
|
fontSize: <number>(parent.settings.base.root as IFakeStyle).fontSize - 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const _platformDefaultsMergedWithProcessor: IFakeTheme = {
|
||||||
|
palette: {
|
||||||
|
bodyBackground: '#000000',
|
||||||
|
bodyText: '#ffffff'
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
families: {
|
||||||
|
primary: 'Platform Font Primary',
|
||||||
|
monospace: 'Platform Font Monospace'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
spacing: { s2: '4px', s1: '8px', m: '16px', l1: '20px', l2: '32px' },
|
||||||
|
settings: {
|
||||||
|
base: {
|
||||||
|
root: {
|
||||||
|
textColor: 'lightblack',
|
||||||
|
fontFamily: 'Verdana Light',
|
||||||
|
fontSize: 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function fakeThemeResolver(parent: IFakeTheme, partial?: Partial<IFakeTheme>): IFakeTheme {
|
||||||
|
let newTheme = immutableMerge({ depth: -1 }, parent, partial) as IFakeTheme;
|
||||||
|
if (newTheme === parent) {
|
||||||
|
newTheme = { ...newTheme };
|
||||||
|
}
|
||||||
|
return newTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestThemeRegistry(platformTheme: IFakeTheme): IThemeRegistry<IFakeTheme, Partial<IFakeTheme>> {
|
||||||
|
return createThemeRegistry<IFakeTheme, Partial<IFakeTheme>>(platformTheme, fakeThemeResolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Theme registry tests', () => {
|
||||||
|
test('getting the platform theme causes an exception', () => {
|
||||||
|
const registry = createTestThemeRegistry(_platformDefaults);
|
||||||
|
expect(() => {
|
||||||
|
registry.getTheme('__platform');
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setting the platform theme causes an exception', () => {
|
||||||
|
const registry = createTestThemeRegistry(_platformDefaults);
|
||||||
|
expect(() => {
|
||||||
|
registry.setTheme({}, '__platform');
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default theme matches platform theme', () => {
|
||||||
|
const registry = createTestThemeRegistry(_platformDefaults);
|
||||||
|
const theme = registry.getTheme();
|
||||||
|
expect(theme).toEqual(_platformDefaults);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('change the default theme', () => {
|
||||||
|
const registry = createTestThemeRegistry(_platformDefaults);
|
||||||
|
registry.setTheme(_ocean);
|
||||||
|
const theme = registry.getTheme();
|
||||||
|
expect(theme).toEqual(_platformDefaultsMergedWithOcean);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create a theme', () => {
|
||||||
|
const registry = createTestThemeRegistry(_platformDefaults);
|
||||||
|
registry.setTheme(_ocean, 'ocean');
|
||||||
|
const theme = registry.getTheme('ocean');
|
||||||
|
expect(theme).toEqual(_platformDefaultsMergedWithOcean);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create a theme which uses a processor', () => {
|
||||||
|
const registry = createTestThemeRegistry(_platformDefaults);
|
||||||
|
registry.setTheme(processor, 'processed');
|
||||||
|
const theme = registry.getTheme('processed');
|
||||||
|
expect(theme).toEqual(_platformDefaultsMergedWithProcessor);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creating a theme that refers to a non-existent parent causes an exception', () => {
|
||||||
|
const registry = createTestThemeRegistry(_platformDefaults);
|
||||||
|
expect(() => {
|
||||||
|
registry.setTheme(_ocean, 'ocean', 'does not exist');
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creating a theme, then updating it to parent to itself causes an exception', () => {
|
||||||
|
const registry = createTestThemeRegistry(_platformDefaults);
|
||||||
|
registry.setTheme(_ocean, 'ocean');
|
||||||
|
expect(() => {
|
||||||
|
registry.setTheme(_ocean, 'ocean', 'ocean');
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creating two themes that parent to each other causes an exception', () => {
|
||||||
|
const registry = createTestThemeRegistry(_platformDefaults);
|
||||||
|
registry.setTheme(_ocean, 'ocean');
|
||||||
|
registry.setTheme(_ocean, 'ocean2', 'ocean');
|
||||||
|
expect(() => {
|
||||||
|
registry.setTheme(_ocean, 'ocean', 'ocean2');
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creating three themes that parent to each other causes an exception', () => {
|
||||||
|
const registry = createTestThemeRegistry(_platformDefaults);
|
||||||
|
registry.setTheme(_ocean, 'ocean');
|
||||||
|
registry.setTheme(_ocean, 'ocean2', 'ocean');
|
||||||
|
registry.setTheme(_ocean, 'ocean3', 'ocean2');
|
||||||
|
expect(() => {
|
||||||
|
registry.setTheme(_ocean, 'ocean', 'ocean3');
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updating the platform defaults changes the default theme', () => {
|
||||||
|
const registry = createTestThemeRegistry(_platformDefaults);
|
||||||
|
const theme = registry.getTheme();
|
||||||
|
registry.updatePlatformDefaults(_platformDefaultsMergedWithOcean);
|
||||||
|
const themeUpdated = registry.getTheme();
|
||||||
|
expect(themeUpdated).not.toEqual(theme);
|
||||||
|
expect(themeUpdated).toEqual(_platformDefaultsMergedWithOcean);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalidation event fires for default theme when platform theme changes', () => {
|
||||||
|
const registry = createTestThemeRegistry(_platformDefaults);
|
||||||
|
const onInvalidate = jest.fn();
|
||||||
|
registry.addEventListener({ onInvalidate });
|
||||||
|
|
||||||
|
registry.getTheme();
|
||||||
|
registry.updatePlatformDefaults(_platformDefaultsMergedWithOcean);
|
||||||
|
|
||||||
|
expect(onInvalidate.mock.calls.length).toEqual(1);
|
||||||
|
expect(onInvalidate.mock.calls[0][0]).toEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalidation event fires for child theme when its parent changes', () => {
|
||||||
|
const registry = createTestThemeRegistry(_platformDefaults);
|
||||||
|
const onInvalidate = jest.fn();
|
||||||
|
registry.addEventListener({ onInvalidate });
|
||||||
|
|
||||||
|
registry.setTheme(_ocean, 'ocean');
|
||||||
|
registry.getTheme('ocean');
|
||||||
|
registry.setTheme({ settings: { View: { root: { backgroundColor: 'red' } } } });
|
||||||
|
|
||||||
|
expect(onInvalidate.mock.calls.length).toEqual(2);
|
||||||
|
expect(onInvalidate.mock.calls[0][0]).toEqual('');
|
||||||
|
expect(onInvalidate.mock.calls[1][0]).toEqual('ocean');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalidation event does not fire when listener is removed', () => {
|
||||||
|
const registry = createTestThemeRegistry(_platformDefaults);
|
||||||
|
const onInvalidate = jest.fn();
|
||||||
|
const listener = { onInvalidate };
|
||||||
|
registry.addEventListener(listener);
|
||||||
|
registry.removeEventListener(listener);
|
||||||
|
|
||||||
|
registry.getTheme();
|
||||||
|
registry.updatePlatformDefaults(_platformDefaultsMergedWithOcean);
|
||||||
|
|
||||||
|
expect(onInvalidate.mock.calls.length).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,218 @@
|
||||||
|
import { IProcessTheme, IThemeEventListener, IThemeRegistry, IResolveTheme } from './Registry.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An entry in the theme registry.
|
||||||
|
*
|
||||||
|
* Each entry has a `parent`, except for the _hidden_ platform entry.
|
||||||
|
*
|
||||||
|
* The entry _may_ contain a `definition`, which is either a partial theme, or
|
||||||
|
* a ProcessTheme() function that produces a partial theme. Either way, the
|
||||||
|
* partial theme is combined with its parent to produce a `resolved` theme.
|
||||||
|
*/
|
||||||
|
interface IEntry {
|
||||||
|
parentEntryName?: string;
|
||||||
|
definition?: object | IProcessTheme<object, object>;
|
||||||
|
resolved?: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collection of theme registry entries, indexed by name.
|
||||||
|
*/
|
||||||
|
interface IEntries {
|
||||||
|
[entryName: string]: IEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `_platformEntry` names the _hidden_ root theme containing platform defaults.
|
||||||
|
* It has no parent, and cannot be accessed directly.
|
||||||
|
*/
|
||||||
|
const _platformEntryName = '__platform';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `_defaultEntry` names the _public_ root theme. It can be changed or replaced.
|
||||||
|
* It is always parented to `_platformEntry`.
|
||||||
|
*/
|
||||||
|
const _defaultEntryName = '__default';
|
||||||
|
|
||||||
|
export function createThemeRegistry<T extends object, TPartial extends object>(
|
||||||
|
initial: T,
|
||||||
|
baseResolver: IResolveTheme<T, TPartial>
|
||||||
|
): IThemeRegistry<T, TPartial> {
|
||||||
|
const entries: IEntries = {
|
||||||
|
[_platformEntryName]: { resolved: initial as object },
|
||||||
|
[_defaultEntryName]: { parentEntryName: _platformEntryName }
|
||||||
|
};
|
||||||
|
const listeners: IThemeEventListener[] = [];
|
||||||
|
const resolver: IResolveTheme<object, object> = (baseResolver as unknown) as IResolveTheme<object, object>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
getTheme: (name?: string) => {
|
||||||
|
return _getTheme(_getEntryName(name), entries, resolver) as T;
|
||||||
|
},
|
||||||
|
setTheme: (definition: TPartial | IProcessTheme<T, TPartial>, name?: string, parent?: string) => {
|
||||||
|
_setTheme(definition, entries, listeners, name, parent);
|
||||||
|
},
|
||||||
|
addEventListener: (events: IThemeEventListener) => {
|
||||||
|
listeners.push(events);
|
||||||
|
},
|
||||||
|
removeEventListener: (events: IThemeEventListener) => {
|
||||||
|
const index = listeners.indexOf(events);
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updatePlatformDefaults: (platformDefaults: TPartial) => {
|
||||||
|
const entry = _getEntry(_platformEntryName, entries);
|
||||||
|
const newPlatformTheme = entry && entry.resolved ? resolver(entry.resolved, platformDefaults) : platformDefaults;
|
||||||
|
_updateEntry(
|
||||||
|
_platformEntryName,
|
||||||
|
{
|
||||||
|
resolved: newPlatformTheme
|
||||||
|
},
|
||||||
|
entries,
|
||||||
|
listeners
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setTheme(
|
||||||
|
definition: object | IProcessTheme<object, object>,
|
||||||
|
entries: IEntries,
|
||||||
|
listeners: IThemeEventListener[],
|
||||||
|
name?: string,
|
||||||
|
parent?: string
|
||||||
|
): void {
|
||||||
|
const entryName = _getEntryName(name);
|
||||||
|
const parentEntryName = entryName === _defaultEntryName ? _platformEntryName : _getEntryName(parent);
|
||||||
|
|
||||||
|
if (!entries.hasOwnProperty(parentEntryName)) {
|
||||||
|
throw new Error('Attempting to parent to an unknown theme');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_wouldCauseCycle(entryName, parentEntryName, entries)) {
|
||||||
|
throw new Error('Attempt to register a dependent theme that would cause a cycle');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: IEntry = { parentEntryName, definition };
|
||||||
|
|
||||||
|
if (entries.hasOwnProperty(entryName)) {
|
||||||
|
_updateEntry(entryName, entry, entries, listeners);
|
||||||
|
} else {
|
||||||
|
entries[entryName] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a public theme name to an internal entry name.
|
||||||
|
*
|
||||||
|
* In most cases, they are the same. However, when `name` is missing or
|
||||||
|
* blank, the default entry name is returned.
|
||||||
|
*
|
||||||
|
* Block access to the _hidden_ platform theme.
|
||||||
|
*/
|
||||||
|
function _getEntryName(name?: string): string {
|
||||||
|
if (name === _platformEntryName) {
|
||||||
|
throw new Error('The platform theme may not be accessed directly');
|
||||||
|
}
|
||||||
|
if (!name || name === '') {
|
||||||
|
return _defaultEntryName;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an entry.
|
||||||
|
*
|
||||||
|
* Invalidate the current entry, if necessary, as well as child entries.
|
||||||
|
*/
|
||||||
|
function _updateEntry(entryName: string, entry: IEntry, entries: IEntries, listeners: IThemeEventListener[]): void {
|
||||||
|
const toInvalidate: string[] = [];
|
||||||
|
if (entryName !== _platformEntryName && entries[entryName].resolved) {
|
||||||
|
toInvalidate.push(entryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
entries[entryName] = entry;
|
||||||
|
|
||||||
|
_clearChildEntries(entryName, toInvalidate, entries);
|
||||||
|
toInvalidate.map(invalidateEntryName => {
|
||||||
|
const invalidateThemeName = invalidateEntryName === _defaultEntryName ? '' : invalidateEntryName;
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener.onInvalidate(invalidateThemeName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a theme entry object.
|
||||||
|
*/
|
||||||
|
function _getEntry(entryName: string, entries: IEntries): IEntry {
|
||||||
|
if (!entries.hasOwnProperty(entryName)) {
|
||||||
|
throw Error('"' + entryName + '" does not exist in the theme registry');
|
||||||
|
}
|
||||||
|
return entries[entryName];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a theme object, resolving it if necessary.
|
||||||
|
*/
|
||||||
|
function _getTheme(entryName: string, entries: IEntries, resolver: IResolveTheme<object, object>): object {
|
||||||
|
const entry = _getEntry(entryName, entries);
|
||||||
|
if (!entry.resolved) {
|
||||||
|
const parentTheme = _getTheme(entry.parentEntryName!, entries, resolver);
|
||||||
|
const definition = _getThemeDefinitionObject(parentTheme, entry.definition);
|
||||||
|
entry.resolved = resolver(parentTheme, definition);
|
||||||
|
}
|
||||||
|
return entry.resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a theme definition object. If necessary, create it using a processor.
|
||||||
|
*/
|
||||||
|
function _getThemeDefinitionObject(parentTheme: object, definition?: object | IProcessTheme<object, object>): object {
|
||||||
|
if (definition) {
|
||||||
|
if (typeof definition === 'function') {
|
||||||
|
const processor = <IProcessTheme<object, object>>definition;
|
||||||
|
return processor(parentTheme);
|
||||||
|
}
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if adding this entry to the hierarchy would cause a cycle.
|
||||||
|
*/
|
||||||
|
function _wouldCauseCycle(entryName: string, parentEntryName: string, entries: IEntries): boolean {
|
||||||
|
while (parentEntryName) {
|
||||||
|
// if we ever find a self-referencing parent there would be a cycle, this
|
||||||
|
// includes parent === name on a single entry
|
||||||
|
if (parentEntryName === entryName) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const parentEntry = entries[parentEntryName];
|
||||||
|
parentEntryName = parentEntry ? parentEntry.parentEntryName! : '';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the `resolved` theme and any cached styles from all child entries
|
||||||
|
* in the hierarchy.
|
||||||
|
*/
|
||||||
|
function _clearChildEntries(parentEntryName: string, toInvalidate: string[], entries: IEntries): void {
|
||||||
|
for (const entryName of Object.getOwnPropertyNames(entries)) {
|
||||||
|
const entry = entries[entryName];
|
||||||
|
if (entry.parentEntryName === parentEntryName && entry.resolved) {
|
||||||
|
// add this theme to the list of those receiving an onInvalidate() callback
|
||||||
|
toInvalidate.push(entryName);
|
||||||
|
|
||||||
|
// remove the theme from the graph
|
||||||
|
entry.resolved = undefined;
|
||||||
|
|
||||||
|
// invalidate all children of this theme entry
|
||||||
|
_clearChildEntries(entryName, toInvalidate, entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
/**
|
||||||
|
* Function which produces a partial theme using only a parent theme.
|
||||||
|
*
|
||||||
|
* Useful for themes which can be computationally created from a base theme.
|
||||||
|
* For example, creating a monochromatic theme from a colorful theme, or
|
||||||
|
* increasing contrast throuhgout a theme.
|
||||||
|
*/
|
||||||
|
export type IProcessTheme<TTheme, TPartialTheme> = (parentTheme: TTheme) => TPartialTheme;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function which resolves a theme + partial theme into a new theme
|
||||||
|
*/
|
||||||
|
export type IResolveTheme<TTheme, TPartialTheme> = (parent: TTheme, partial?: TPartialTheme) => TTheme;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events issued from the theme registry.
|
||||||
|
*/
|
||||||
|
export interface IThemeEventListener {
|
||||||
|
/**
|
||||||
|
* Called when a theme is invalidated.
|
||||||
|
*
|
||||||
|
* This happens when one or more of its parent themes is invalidated or
|
||||||
|
* changed. Any theme objects matching this name should be discarded. The
|
||||||
|
* updated theme can be retrieved from the registry, when needed.
|
||||||
|
*
|
||||||
|
* `name` identifies the invalid theme. A blank name refers to the default
|
||||||
|
* theme.
|
||||||
|
*/
|
||||||
|
onInvalidate(name: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hierarchical collection of themes.
|
||||||
|
*/
|
||||||
|
export interface IThemeRegistry<TTheme, TPartialTheme> {
|
||||||
|
/**
|
||||||
|
* Get a theme using `name`.
|
||||||
|
*
|
||||||
|
* When `name` is missing or blank, the default theme is retrieved.
|
||||||
|
*/
|
||||||
|
getTheme(name?: string): TTheme;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add or update a theme.
|
||||||
|
*
|
||||||
|
* `definition` _indirectly_ defines the theme. It is either a partial theme
|
||||||
|
* or a function that produces a partial theme using the parent theme. Either
|
||||||
|
* way, the partial theme is combined with its parent to produce a resolved
|
||||||
|
* theme.
|
||||||
|
*
|
||||||
|
* `name` identifies the theme in the registry. When missing or blank, the
|
||||||
|
* default theme is used.
|
||||||
|
*
|
||||||
|
* `parent` identifies the parent theme. When missing or blank, the default
|
||||||
|
* theme is used.
|
||||||
|
*/
|
||||||
|
setTheme(definition: TPartialTheme | IProcessTheme<TTheme, TPartialTheme>, name?: string, parent?: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen for theming events.
|
||||||
|
*/
|
||||||
|
addEventListener(events: IThemeEventListener): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop listening for theming events. Use the same object here that was used
|
||||||
|
* when calling `addEventListener`.
|
||||||
|
*/
|
||||||
|
removeEventListener(events: IThemeEventListener): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the _hidden_ root platform theme using `platformDefaults`.
|
||||||
|
*
|
||||||
|
* **NOTE**: Only the native platform should call this method.
|
||||||
|
*/
|
||||||
|
updatePlatformDefaults(platformDefaults: TPartialTheme): void;
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './Registry.types';
|
||||||
|
export * from './Registry';
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "lib"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
src
|
||||||
|
node_modules
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.editorconfig
|
||||||
|
config.js
|
||||||
|
jest.config.js
|
||||||
|
tslint.json
|
||||||
|
tsconfig.json
|
||||||
|
jsconfig.json
|
||||||
|
webpack.config.js
|
||||||
|
webpack.serve.config.js
|
||||||
|
*.build.log
|
|
@ -0,0 +1,142 @@
|
||||||
|
# Theme Settings
|
||||||
|
|
||||||
|
Theme settings represent the configuration data for a component and can be used for both simple components as well as higher order components. There are a few main concepts that get combined in this package.
|
||||||
|
|
||||||
|
## ISlotProps
|
||||||
|
|
||||||
|
export interface ISlotProps<TProps extends object = object> {
|
||||||
|
root: TProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
Slot props or `ISlotProps` represent one or more sets of properties that correspond to the parts of a component. The pattern establishes that there must be an entry called root for the main component, then there can be additional named components. Consider the following two examples:
|
||||||
|
|
||||||
|
### Simple Label Example
|
||||||
|
|
||||||
|
In react.js, this might be a `div` wrapping a string, in native it might wrap the native text control. Let's consider the native scenario where simple label adds a labelStyle prop to the base text control. The interface and slot props might look as follows:
|
||||||
|
|
||||||
|
interface ISimpleLabelProps extends TextProps {
|
||||||
|
labelStyle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISimpleLabelSlotProps {
|
||||||
|
root: ISimpleLabelProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
### Two Line Button Example
|
||||||
|
|
||||||
|
As a second example consider a button that has two lines of text. The structure of the button will have an outer container and then two simple label controls arranged vertically. In this case the control has three slots: the outer container, and the two labels. The props and slot props might look as follows:
|
||||||
|
|
||||||
|
interface ITwoLineButtonProps extends ViewProps {
|
||||||
|
topText: string;
|
||||||
|
bottomText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ITwoLineButtonSlotProps {
|
||||||
|
root: ITwoLineButtonProps;
|
||||||
|
topText: ISimpleLabelProps;
|
||||||
|
bottomText: ISimpleLabelProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
### Why the Slot Props Pattern?
|
||||||
|
|
||||||
|
The slot props pattern was chosen for a number of reasons.
|
||||||
|
|
||||||
|
- It creates a standard way of handling strongly typed named collections of props.
|
||||||
|
- It allows framework code to process both simple and higher order components without special casing.
|
||||||
|
- It allows the sub-objects, including root, to be passed directly to components as props without needing to strip values.
|
||||||
|
- It is agnostic to what is in each entry, avoiding name collisions.
|
||||||
|
|
||||||
|
## IComponentSettings
|
||||||
|
|
||||||
|
The IComponentSettings interface is an extension of ISlotProps which is designed to allow authoring settings for a component. It has the following form:
|
||||||
|
|
||||||
|
export type IComponentSettings<TSlotProps extends ISlotProps = ISlotProps> =
|
||||||
|
IPartialSlotProps<TSlotProps> & {
|
||||||
|
_parent?: string | string[];
|
||||||
|
_precedence?: string[];
|
||||||
|
_overrides?: {
|
||||||
|
[key: string]: IComponentSettings<TSlotProps>
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
### Partial ISlotProps
|
||||||
|
|
||||||
|
Props interfaces have a mix of required and optional parameters. When defining settings in places such as theme definitions this is not desireable as those values will need to be filled from the actual props passed into the component. As a result `IComponentSettings<IMySlotProps>` will make the slot props into a partial object.
|
||||||
|
|
||||||
|
### \_parent?: string | string[]
|
||||||
|
|
||||||
|
When used in a theme, the `_parent` property allows inheritance from one or more previously declared settings. Settings blocks references as parents will be applied earlier in the precedence chain, meaning values in the current settings block will overwrite values coming from the parents. As an example:
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
settings: {
|
||||||
|
Obj1: {
|
||||||
|
root: {
|
||||||
|
val1: 'foo'
|
||||||
|
val2: 'bar'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Obj2: {
|
||||||
|
root: {
|
||||||
|
val2: 'baz'
|
||||||
|
val3: 'foobar'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Obj3: {
|
||||||
|
_parent: ['Obj1', 'Obj2'],
|
||||||
|
root: {
|
||||||
|
val3: 'this will win'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
When resolving Obj3 the result will be:
|
||||||
|
|
||||||
|
{
|
||||||
|
_parent: ['Obj1', 'Obj2'],
|
||||||
|
root: {
|
||||||
|
val1: 'foo', // coming from Obj1
|
||||||
|
val2: 'baz', // value from Obj2 overwrote value from Obj1
|
||||||
|
val3: 'thsi will win' // value from Obj3 overwrote value from Obj2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#### \_overrides and \_precedence
|
||||||
|
|
||||||
|
The overrides define recursive IComponentSettings that will be applied to the root in the order defined by \_precedence. These will be merged one by one, supplanting values at the root level and potentially supplying additional overrides.
|
||||||
|
|
||||||
|
For the simple label example above this might work as follows:
|
||||||
|
|
||||||
|
const labelSettings: IComponentSettings<ISimpleLabelSlotProps> = {
|
||||||
|
root: {
|
||||||
|
style: { color: 'black' }
|
||||||
|
},
|
||||||
|
_precedence: ['primary', 'hovered', 'disabled'],
|
||||||
|
_overrides: {
|
||||||
|
disabled: { root: { style: { color: '#a3a3a3' } } },
|
||||||
|
hovered: { root: { style: { color: '#c2c2c2' } } },
|
||||||
|
primary: {
|
||||||
|
root: { style: { color: 'white' } },
|
||||||
|
_overrides: {
|
||||||
|
disabled: { root: { style: { color: '#1d1d1d' } } },
|
||||||
|
hovered: { root: { style: { color: 'white' } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
The resulting color value would be:
|
||||||
|
|
||||||
|
- _no overrides_ - black
|
||||||
|
- _disabled_ - #a3a3a3
|
||||||
|
- _hovered_ - #c2c2c2
|
||||||
|
- _primary_ - white
|
||||||
|
- _primary disabled_ - #1d1d1d
|
||||||
|
- _primary hovered_ - white
|
||||||
|
- _primary disabled hovered_ - #1d1d1d
|
||||||
|
|
||||||
|
The ability to mix in layers of overrides in a recursive manner allows the overrides to be used to provide both alternate styles and states for a component.
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
Style handling is described in [Styles.md](./Styles.md)
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Style Handling
|
||||||
|
|
||||||
|
Settings structures internally contain entries for style objects. These follow the same pattern as react-native stock controls, allowing objects, and recursive arrays of objects. When settings are merged these require special handling.
|
||||||
|
|
||||||
|
## IStyleProp
|
||||||
|
|
||||||
|
This is a copy of the StyleProp definition from `react-native`. This is copied primarily in the case where it is used in web code where adding a dependency on the `react-native` package itself is not desireable.
|
||||||
|
|
||||||
|
The StyleProp pattern itself is allows a style to be provided as a style or a recursive array of styles. So the following pattern is allowed:
|
||||||
|
|
||||||
|
props = {
|
||||||
|
style: [
|
||||||
|
{ ...style1 },
|
||||||
|
[
|
||||||
|
{ ...style2 },
|
||||||
|
{ ...style3 },
|
||||||
|
[
|
||||||
|
{ ...style4 }
|
||||||
|
]
|
||||||
|
],
|
||||||
|
{ ...style5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
In this model merging styles can be effectively deferred by the following:
|
||||||
|
|
||||||
|
const styleToMerge = { ...values };
|
||||||
|
props.style = [props.style, styleToMerge];
|
||||||
|
|
||||||
|
The registered style pattern referenced in the type is still not implemented in react-native, even after being in there with a todo for several years. It is an interesting pattern and the use of an augmented number object might be something to explore.
|
||||||
|
|
||||||
|
## flattenStyle
|
||||||
|
|
||||||
|
This is a port of the flatten routine from react-native. While this is provided as part of the style sheet implementation, the base function itself is not directly exposed.
|
||||||
|
|
||||||
|
This routine simply merges all of the styles together in the order they are found and produces a single flattened and merged style object.
|
||||||
|
|
||||||
|
## mergeAndFinalizeStyles
|
||||||
|
|
||||||
|
This routine is used as the merge handler when merging `IComponentSettings` objects which contain styles. It will merge and flatten the styles together and optionally resolve theme values if a theme and finalizer are passed in.
|
||||||
|
|
||||||
|
The flattening is done here so that if and when values are cached they are cached in a form that is ready to apply to the actual components. With caching comes the assumption that work done before the caching happens will happen less frequently than the usage outside.
|
||||||
|
|
||||||
|
## Future Explorations
|
||||||
|
|
||||||
|
Here are some quick thoughts on future explorations to do here.
|
||||||
|
|
||||||
|
- Explore filling in the number & { } pattern in props to create a reference to a common index. This could be used for things like caching repeated merge results. An example would be looking up that merging #1 and #2 should produce #3. If #1 and #2 are encountered again #3 can be used without needing to create a new object.
|
||||||
|
- The number pattern is interesting for web because of CSS rule creation being expensive. When styles are turned into rules it is important that we create the minimum number of rules. This is really the same optimization as creating the minimum number of objects.
|
||||||
|
- Look at adding rule creation for web as part of the finalization.
|
|
@ -0,0 +1 @@
|
||||||
|
module.exports = require('../../scripts/jest.config');
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "@uifabric/theme-settings",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "Settings and style definitions and helpers",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/microsoft/ui-fabric-react-native"
|
||||||
|
},
|
||||||
|
"main": "lib/index.js",
|
||||||
|
"typings": "lib/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "tsc -w --preserveWatchOutput",
|
||||||
|
"test": "jest",
|
||||||
|
"start-test": "jest --watch"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@uifabric/immutable-merge": "0.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/es6-collections": "^0.5.29",
|
||||||
|
"@types/es6-promise": "0.0.32",
|
||||||
|
"@types/node": "^10.3.5",
|
||||||
|
"@types/jest": "^19.2.2"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { IComponentSettings } from './Settings.types';
|
||||||
|
import { mergeSettings } from './Settings';
|
||||||
|
import { IStyleProp } from './Styles.types';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
root: {
|
||||||
|
prop1: string;
|
||||||
|
style: IStyleProp<{
|
||||||
|
fontFamily?: string;
|
||||||
|
fontWeight?: 'light' | 'normal' | 'bold';
|
||||||
|
fontSize?: number;
|
||||||
|
textColor?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
slot1: {
|
||||||
|
background?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsDefault: IComponentSettings<IProps> = {
|
||||||
|
root: {
|
||||||
|
style: {
|
||||||
|
fontFamily: 'Calibri',
|
||||||
|
fontWeight: 'normal',
|
||||||
|
fontSize: 12,
|
||||||
|
textColor: 'black'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsBase: IComponentSettings<IProps> = {
|
||||||
|
root: {
|
||||||
|
style: {
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: 16,
|
||||||
|
textColor: 'blue'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_overrides: {
|
||||||
|
hover: {
|
||||||
|
root: {
|
||||||
|
style: {
|
||||||
|
textColor: 'lightblue'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsNormal: IComponentSettings<IProps> = {
|
||||||
|
root: {
|
||||||
|
style: [
|
||||||
|
{
|
||||||
|
fontFamily: 'Calibri Body'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fontSize: 10,
|
||||||
|
textColor: 'darkgray'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
_overrides: {
|
||||||
|
hover: {
|
||||||
|
root: {
|
||||||
|
style: {
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Merge settings tests', () => {
|
||||||
|
test('mergeSettings produces an empty settings when no settings are given', () => {
|
||||||
|
const merged = mergeSettings();
|
||||||
|
expect(merged).toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merging one settings without overrides produces an object that matches the input', () => {
|
||||||
|
const merged = mergeSettings(settingsDefault);
|
||||||
|
expect(merged).toEqual(settingsDefault);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merging one settings without overrides does not produce a new object', () => {
|
||||||
|
const merged = mergeSettings(settingsDefault);
|
||||||
|
expect(merged).toBe(settingsDefault);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merging one settings produces an object that matches the input', () => {
|
||||||
|
const merged = mergeSettings(settingsNormal);
|
||||||
|
expect(merged).toEqual(settingsNormal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merging one settings does not produce a new object', () => {
|
||||||
|
const merged = mergeSettings(settingsNormal);
|
||||||
|
expect(merged).toBe(settingsNormal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merging one settings produces an object with an 'overrides' prop identical to the input settings overrides prop", () => {
|
||||||
|
const merged = mergeSettings(settingsNormal);
|
||||||
|
expect(merged._overrides).toBe(settingsNormal._overrides);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merging two settings, one without overrides, produces a blended object', () => {
|
||||||
|
const merged = mergeSettings(settingsDefault, settingsBase);
|
||||||
|
expect(merged).toEqual({
|
||||||
|
root: {
|
||||||
|
style: {
|
||||||
|
fontFamily: 'Calibri',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: 16,
|
||||||
|
textColor: 'blue'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_overrides: {
|
||||||
|
hover: {
|
||||||
|
root: {
|
||||||
|
style: {
|
||||||
|
textColor: 'lightblue'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
"merging two settings, one without overrides, produces an object with an 'overrides' prop " +
|
||||||
|
'identical to the input settings overrides prop',
|
||||||
|
() => {
|
||||||
|
const merged = mergeSettings(settingsDefault, settingsBase);
|
||||||
|
expect(merged._overrides).toBe(settingsBase._overrides);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test('merging two settings, both with overrides, produces a blended object', () => {
|
||||||
|
const merged = mergeSettings(settingsBase, settingsNormal);
|
||||||
|
expect(merged).toEqual({
|
||||||
|
root: {
|
||||||
|
style: {
|
||||||
|
fontFamily: 'Calibri Body',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: 10,
|
||||||
|
textColor: 'darkgray'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_overrides: {
|
||||||
|
hover: {
|
||||||
|
root: {
|
||||||
|
style: {
|
||||||
|
textColor: 'lightblue',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merging three settings produces a blended object', () => {
|
||||||
|
const merged = mergeSettings(settingsDefault, settingsBase, settingsNormal);
|
||||||
|
expect(merged).toEqual({
|
||||||
|
root: {
|
||||||
|
style: {
|
||||||
|
fontFamily: 'Calibri Body',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: 10,
|
||||||
|
textColor: 'darkgray'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_overrides: {
|
||||||
|
hover: {
|
||||||
|
root: {
|
||||||
|
style: {
|
||||||
|
textColor: 'lightblue',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { IComponentSettings } from './Settings.types';
|
||||||
|
import { resolveSettingsOverrides } from './Settings';
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
root: {
|
||||||
|
fontFamily?: string;
|
||||||
|
fontWeight?: 'light' | 'normal' | 'bold';
|
||||||
|
fontSize?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsBase: IComponentSettings<IProps> = {
|
||||||
|
root: {
|
||||||
|
fontFamily: 'Arial'
|
||||||
|
},
|
||||||
|
_precedence: ['hover', 'hover2'],
|
||||||
|
_overrides: {
|
||||||
|
hover: {
|
||||||
|
root: {
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hover2: {
|
||||||
|
root: {
|
||||||
|
fontWeight: 'light',
|
||||||
|
fontSize: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Override settings tests', () => {
|
||||||
|
test('applyOverrides returns the input object when no overrides are given', () => {
|
||||||
|
const overridden = resolveSettingsOverrides(settingsBase);
|
||||||
|
expect(overridden).toBe(settingsBase);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyOverrides returns the input object when the override does not exist', () => {
|
||||||
|
const overridden = resolveSettingsOverrides(settingsBase, { foo: true });
|
||||||
|
expect(overridden).toBe(settingsBase);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyOverrides applies the hover2 override using only the input', () => {
|
||||||
|
const overridden = resolveSettingsOverrides(settingsBase, { hover2: true });
|
||||||
|
expect(overridden).toEqual({
|
||||||
|
root: {
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
fontWeight: 'light',
|
||||||
|
fontSize: 5
|
||||||
|
},
|
||||||
|
_precedence: ['hover', 'hover2'],
|
||||||
|
_overrides: {
|
||||||
|
hover: {
|
||||||
|
root: {
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hover2: {
|
||||||
|
root: {
|
||||||
|
fontWeight: 'light',
|
||||||
|
fontSize: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyOverrides applies the hover override, followed by the hover2 override', () => {
|
||||||
|
const overridden = resolveSettingsOverrides(settingsBase, { hover2: true, hover: true });
|
||||||
|
expect(overridden).toEqual({
|
||||||
|
root: {
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
fontWeight: 'light',
|
||||||
|
fontSize: 5
|
||||||
|
},
|
||||||
|
_precedence: ['hover', 'hover2'],
|
||||||
|
_overrides: {
|
||||||
|
hover: {
|
||||||
|
root: {
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hover2: {
|
||||||
|
root: {
|
||||||
|
fontWeight: 'light',
|
||||||
|
fontSize: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { IComponentSettings, IComponentSettingsCollection } from './Settings.types';
|
||||||
|
import { getParentSettingsChain, mergeSettings } from './Settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve any parent references for the settings, using the `lookup` collection of settings to lookup
|
||||||
|
* parents by name.
|
||||||
|
*
|
||||||
|
* The process starts with the settings specified by `target`. Settings are merged from the oldest
|
||||||
|
* parent forward, up to and including the source settings.
|
||||||
|
*/
|
||||||
|
function resolveSettingsParents(lookup: IComponentSettingsCollection, target: string | IComponentSettings): IComponentSettings {
|
||||||
|
const collectedSettings = getParentSettingsChain(lookup, target);
|
||||||
|
// merge the hierarhcy
|
||||||
|
return mergeSettings(...collectedSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
root: {
|
||||||
|
fontFamily?: string;
|
||||||
|
fontWeight?: 'light' | 'normal' | 'bold';
|
||||||
|
fontSize?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsGrandParentName = 'settingsGrandParent';
|
||||||
|
const settingsGrandParent: IComponentSettings<IProps> = {
|
||||||
|
root: {
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
fontWeight: 'light',
|
||||||
|
fontSize: 12
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsParentName = 'settingsParent';
|
||||||
|
const settingsParent: IComponentSettings<IProps> = {
|
||||||
|
_parent: settingsGrandParentName,
|
||||||
|
root: {
|
||||||
|
fontFamily: 'Verdana',
|
||||||
|
fontWeight: 'normal'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsChildName = 'settingsChildName';
|
||||||
|
const settingsChild: IComponentSettings<IProps> = {
|
||||||
|
_parent: settingsParentName,
|
||||||
|
root: {
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
fontSize: 11
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptySettingsName = 'emptyLayerName';
|
||||||
|
const emptySettings: IComponentSettings<IProps> = {};
|
||||||
|
|
||||||
|
const collection: IComponentSettingsCollection<IComponentSettings<IProps>> = {};
|
||||||
|
collection[settingsGrandParentName] = settingsGrandParent;
|
||||||
|
collection[settingsParentName] = settingsParent;
|
||||||
|
collection[settingsChildName] = settingsChild;
|
||||||
|
collection[emptySettingsName] = emptySettings;
|
||||||
|
|
||||||
|
describe('Resolve settings parents tests', () => {
|
||||||
|
test('resolveSettingsParents returns undefined when the source settings does not exist', () => {
|
||||||
|
const flattened: IComponentSettings = resolveSettingsParents({}, 'does not exist');
|
||||||
|
expect(flattened).toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveSettingsParents returns undefined when the source settings is empty', () => {
|
||||||
|
const flattened: IComponentSettings<IProps> = resolveSettingsParents(collection, emptySettingsName);
|
||||||
|
expect(flattened).toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveSettingsParents returns the grand-parent settings when starting with the grand-parent settings', () => {
|
||||||
|
const flattened: IComponentSettings = resolveSettingsParents(collection, settingsGrandParentName);
|
||||||
|
expect(flattened).toEqual(settingsGrandParent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveSettingsParents blends the parent and grand-parent layers when starting with the parent settings', () => {
|
||||||
|
const flattened = resolveSettingsParents(collection, settingsParentName);
|
||||||
|
expect(flattened).toEqual({
|
||||||
|
_parent: settingsGrandParentName,
|
||||||
|
root: {
|
||||||
|
fontFamily: 'Verdana',
|
||||||
|
fontWeight: 'normal',
|
||||||
|
fontSize: 12
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveSettingsParents blends the child, parent and grand-parent when starting with child settings', () => {
|
||||||
|
const flattened = resolveSettingsParents(collection, settingsChildName);
|
||||||
|
expect(flattened).toEqual({
|
||||||
|
_parent: settingsParentName,
|
||||||
|
root: {
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
fontWeight: 'normal',
|
||||||
|
fontSize: 11
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { IMergeOptions, immutableMerge } from '@uifabric/immutable-merge';
|
||||||
|
import { IComponentSettingsCollection, IComponentSettings, ISlotProps, IOverrideLookup } from './Settings.types';
|
||||||
|
import { mergeAndFinalizeStyles } from './Styles';
|
||||||
|
import { IFinalizeStyle, IStyleProp } from './Styles.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* helper function to switch to a collection merge pattern when _overrides are encountered
|
||||||
|
*/
|
||||||
|
function _mergeCollection(_key: string, _options: IMergeOptions, ...objs: (object | undefined)[]): IComponentSettingsCollection {
|
||||||
|
return mergeSettingsCollection(...(objs as IComponentSettingsCollection[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _mergeStyles(_key: string, _options: IMergeOptions, ...objs: (IStyleProp<object>)[]): object | undefined {
|
||||||
|
return mergeAndFinalizeStyles(undefined, undefined, ...objs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* styles should be flattened and merged
|
||||||
|
* tokens should be merged one level
|
||||||
|
* overrides should be handled as a collection
|
||||||
|
*/
|
||||||
|
const _recurseOptions = {
|
||||||
|
style: _mergeStyles,
|
||||||
|
tokens: true,
|
||||||
|
_overrides: _mergeCollection
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* for a single settings block, recurse one level deep to include the various slot props
|
||||||
|
*/
|
||||||
|
const _mergeSettingsOptions: IMergeOptions = {
|
||||||
|
depth: 1,
|
||||||
|
recurse: _recurseOptions
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* for a collection of settings, recurse two levels
|
||||||
|
*/
|
||||||
|
const _mergeCollectionOptions: IMergeOptions = {
|
||||||
|
depth: 2,
|
||||||
|
recurse: _recurseOptions
|
||||||
|
};
|
||||||
|
|
||||||
|
const _mergePropsOptions: IMergeOptions = {
|
||||||
|
recurse: { style: _mergeStyles, tokens: true }
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge settings together. This routine should work for IComponentSettings types or ISlotProps
|
||||||
|
* @param settings - settings to merge together
|
||||||
|
*/
|
||||||
|
export function mergeSettings<TSettings extends IComponentSettings = IComponentSettings>(...settings: (object | undefined)[]): TSettings {
|
||||||
|
return immutableMerge(_mergeSettingsOptions, ...settings) as TSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge props together, flattening and merging styles as appropriate
|
||||||
|
* @param props - props to merge together
|
||||||
|
*/
|
||||||
|
export function mergeProps<TProps extends object>(...props: (object | undefined)[]): TProps {
|
||||||
|
return immutableMerge(_mergePropsOptions, ...props) as TProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge settings together and run finalization as part of the process
|
||||||
|
*
|
||||||
|
* @param theme - theme to use for value lookups
|
||||||
|
* @param finalizers - finalizers to use for style processing
|
||||||
|
* @param settings - settings to merge, can be only a single entry
|
||||||
|
*/
|
||||||
|
export function mergeAndFinalizeSettings<TSettings extends IComponentSettings = IComponentSettings>(
|
||||||
|
finalizer: IFinalizeStyle,
|
||||||
|
...settings: (object | undefined)[]
|
||||||
|
): TSettings {
|
||||||
|
const mergeOptions: IMergeOptions = {
|
||||||
|
depth: 1,
|
||||||
|
processSingles: true,
|
||||||
|
recurse: {
|
||||||
|
..._recurseOptions,
|
||||||
|
style: (_key: string, _options: IMergeOptions, ...objs: (IStyleProp<object>)[]) => {
|
||||||
|
return mergeAndFinalizeStyles(finalizer, ...objs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return immutableMerge(mergeOptions, ...settings) as TSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge collections of settings together. This can handle theme resolution or merging sets of overrides
|
||||||
|
* @param collections - the settings collections to merge
|
||||||
|
*/
|
||||||
|
export function mergeSettingsCollection<TCollection extends IComponentSettingsCollection = IComponentSettingsCollection>(
|
||||||
|
...collections: object[]
|
||||||
|
): TCollection {
|
||||||
|
return immutableMerge(_mergeCollectionOptions, ...collections) as TCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the chain of parents, calling the visitor function on each one
|
||||||
|
*/
|
||||||
|
function visitSettingsHierarchyDepthFirst(
|
||||||
|
collection: IComponentSettingsCollection,
|
||||||
|
target: string | IComponentSettings,
|
||||||
|
visitor: (settings: IComponentSettings) => void
|
||||||
|
): void {
|
||||||
|
const isSettings = typeof target === 'object';
|
||||||
|
if (isSettings || collection.hasOwnProperty(target as string)) {
|
||||||
|
const settings = isSettings ? (target as IComponentSettings) : collection[target as string];
|
||||||
|
|
||||||
|
if (settings) {
|
||||||
|
// visit parents first
|
||||||
|
if (settings._parent) {
|
||||||
|
const parents = Array.isArray(settings._parent) ? settings._parent : [settings._parent];
|
||||||
|
for (const parent of parents) {
|
||||||
|
visitSettingsHierarchyDepthFirst(collection, parent, visitor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// visit this layer
|
||||||
|
visitor(settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the set of settings, in the order they should be merged, to resolve the parent chain
|
||||||
|
*
|
||||||
|
* @param lookup - collection to use for looking up settings
|
||||||
|
* @param target - settings entry to use as the root of the lookup chain
|
||||||
|
*/
|
||||||
|
export function getParentSettingsChain(lookup: IComponentSettingsCollection, target: string | IComponentSettings): IComponentSettings[] {
|
||||||
|
// gather the entire settings hierarchy into an ordered array
|
||||||
|
const collectedSettings: IComponentSettings[] = [];
|
||||||
|
visitSettingsHierarchyDepthFirst(lookup, target, settings => collectedSettings.push(settings));
|
||||||
|
return collectedSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply overrides to `target`, producing a new settings object if any need to be applied.
|
||||||
|
*
|
||||||
|
* `overrideLookup` is an object where keys will be looked up in the order specified by the precedence array.
|
||||||
|
* The values inside this structure can be any type but will cause the override to apply if they are truthy
|
||||||
|
*/
|
||||||
|
export function resolveSettingsOverrides(target: IComponentSettings, overrideLookup?: IOverrideLookup): IComponentSettings {
|
||||||
|
let result = target;
|
||||||
|
const { _overrides, _precedence } = target;
|
||||||
|
if (overrideLookup && _overrides && _precedence) {
|
||||||
|
for (const override of _precedence) {
|
||||||
|
if (_overrides[override] && overrideLookup[override]) {
|
||||||
|
result = mergeSettings(result, _overrides[override]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a settings object into a slot props object.
|
||||||
|
* @param target - settings block to strip the settings specific information from
|
||||||
|
*/
|
||||||
|
export function slotPropsFromSettings(target: IComponentSettings): ISlotProps {
|
||||||
|
const { _overrides, _parent, _precedence, ...slotProps } = target;
|
||||||
|
return slotProps as ISlotProps;
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
export interface ISlotProps<TProps extends object = object> {
|
||||||
|
root: TProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IPartialSlotProps<TSlotProps extends ISlotProps> = { [K in keyof TSlotProps]+?: Partial<TSlotProps[K]> };
|
||||||
|
|
||||||
|
export type IComponentSettings<TSlotProps extends ISlotProps = ISlotProps> = IPartialSlotProps<TSlotProps> & {
|
||||||
|
_parent?: string | string[];
|
||||||
|
_precedence?: string[];
|
||||||
|
_overrides?: IComponentSettingsCollection<IComponentSettings<TSlotProps>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IComponentSettingsCollection<TSettings extends IComponentSettings = IComponentSettings> = {
|
||||||
|
[key: string]: TSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* overrides are looked up using an object where override names are evaluated against the object. If the values are truthy
|
||||||
|
* the override will be applied.
|
||||||
|
*/
|
||||||
|
export interface IOverrideLookup {
|
||||||
|
/* tslint:disable-next-line no-any */
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { flattenStyle, mergeAndFinalizeStyles } from './Styles';
|
||||||
|
import { IFinalizeStyle, IStyleProp } from './Styles.types';
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
palette: {
|
||||||
|
bodyBackground: '#ff0000',
|
||||||
|
bodyText: '#000000'
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
families: {
|
||||||
|
primary: 'Arial'
|
||||||
|
},
|
||||||
|
sizes: {
|
||||||
|
medium: 14
|
||||||
|
},
|
||||||
|
weights: {
|
||||||
|
medium: '500'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IFakeStyle {
|
||||||
|
backgroundColor?: string;
|
||||||
|
color?: string;
|
||||||
|
fontFamily?: string;
|
||||||
|
borderWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styleFinalizer: IFinalizeStyle = (target: IFakeStyle) => {
|
||||||
|
const newStyle: IFakeStyle = {};
|
||||||
|
if (target.backgroundColor) {
|
||||||
|
const newVal = theme.palette[target.backgroundColor];
|
||||||
|
if (newVal) {
|
||||||
|
newStyle.backgroundColor = newVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.color) {
|
||||||
|
const newVal = theme.palette[target.color];
|
||||||
|
if (newVal) {
|
||||||
|
newStyle.color = newVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.fontFamily) {
|
||||||
|
const newVal = theme.typography.families[target.fontFamily];
|
||||||
|
if (newVal) {
|
||||||
|
newStyle.fontFamily = newVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newStyle;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IFakeStyleProp = IStyleProp<IFakeStyle>;
|
||||||
|
|
||||||
|
const s1: IFakeStyleProp = [
|
||||||
|
{ backgroundColor: 'blue' },
|
||||||
|
[{ color: 'red', borderWidth: 1 }, { fontFamily: 'segoe' }, [{ backgroundColor: 'bodyBackground' }]]
|
||||||
|
];
|
||||||
|
|
||||||
|
const s1flatten: IFakeStyleProp = {
|
||||||
|
backgroundColor: 'bodyBackground',
|
||||||
|
color: 'red',
|
||||||
|
borderWidth: 1,
|
||||||
|
fontFamily: 'segoe'
|
||||||
|
};
|
||||||
|
|
||||||
|
const s1flattenFinal: IFakeStyleProp = {
|
||||||
|
backgroundColor: theme.palette.bodyBackground,
|
||||||
|
color: 'red',
|
||||||
|
borderWidth: 1,
|
||||||
|
fontFamily: 'segoe'
|
||||||
|
};
|
||||||
|
|
||||||
|
const s2: IFakeStyleProp = {
|
||||||
|
borderWidth: 2,
|
||||||
|
fontFamily: 'primary',
|
||||||
|
color: 'bodyText'
|
||||||
|
};
|
||||||
|
|
||||||
|
const s2Final: IFakeStyleProp = {
|
||||||
|
borderWidth: 2,
|
||||||
|
fontFamily: theme.typography.families.primary,
|
||||||
|
color: theme.palette.bodyText
|
||||||
|
};
|
||||||
|
|
||||||
|
const sMerged: IFakeStyleProp = {
|
||||||
|
backgroundColor: 'bodyBackground',
|
||||||
|
borderWidth: 2,
|
||||||
|
fontFamily: 'primary',
|
||||||
|
color: 'bodyText'
|
||||||
|
};
|
||||||
|
|
||||||
|
const sMergedFinal: IFakeStyleProp = {
|
||||||
|
...s1flattenFinal,
|
||||||
|
...s2Final
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Style flatten and merge tests', () => {
|
||||||
|
test('flatten recursive arrays', () => {
|
||||||
|
const flattened = flattenStyle(s1);
|
||||||
|
expect(flattened).toEqual(s1flatten);
|
||||||
|
expect(flattened).not.toBe(s1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flatten flat style returns style', () => {
|
||||||
|
const flattened = flattenStyle(s2);
|
||||||
|
expect(flattened).toBe(s2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merge also flattens', () => {
|
||||||
|
const merged = mergeAndFinalizeStyles(undefined, undefined, s1, s2);
|
||||||
|
expect(merged).toEqual(sMerged);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finalize single style', () => {
|
||||||
|
const final = mergeAndFinalizeStyles(styleFinalizer, s1);
|
||||||
|
expect(final).toEqual(s1flattenFinal);
|
||||||
|
|
||||||
|
const final2 = mergeAndFinalizeStyles(styleFinalizer, s2);
|
||||||
|
expect(final2).toEqual(s2Final);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merge and finalize style', () => {
|
||||||
|
const mergedAndFinal = mergeAndFinalizeStyles(styleFinalizer, s1, s2);
|
||||||
|
expect(mergedAndFinal).toEqual(sMergedFinal);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { immutableMerge } from '@uifabric/immutable-merge';
|
||||||
|
import { IFinalizeStyle, IStyleProp } from './Styles.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a react-native style, which may be a recursive array, and return as a flattened
|
||||||
|
* style. This is analagous to the flatten routine that is part of the style sheet API
|
||||||
|
*
|
||||||
|
* @param style - StyleProp<TStyle> to flatten, this can be a TStyle or an array
|
||||||
|
*/
|
||||||
|
export function flattenStyle(style: IStyleProp<object>): object {
|
||||||
|
if (style === null || typeof style !== 'object') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(style)) {
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
for (let i = 0, styleLength = style.length; i < styleLength; ++i) {
|
||||||
|
Object.assign(result, flattenStyle(style[i]));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge styles together into a single flat object and optionally finalize them, can also be used to finalize a single style
|
||||||
|
*
|
||||||
|
* @param styles - array of styles to merge together. The styles will be flattened as part of the process
|
||||||
|
*/
|
||||||
|
export function mergeAndFinalizeStyles(finalizer: IFinalizeStyle | undefined, ...styles: IStyleProp<object>[]): object | undefined {
|
||||||
|
// baseline merge and flatten the objects
|
||||||
|
let merged = immutableMerge(
|
||||||
|
{},
|
||||||
|
...styles.map((styleProp: IStyleProp<object>) => {
|
||||||
|
return flattenStyle(styleProp);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// if the styles should be finalized as part of this do that as well
|
||||||
|
if (finalizer && merged) {
|
||||||
|
const updated = finalizer(merged);
|
||||||
|
if (updated && Object.keys(updated).length > 0) {
|
||||||
|
merged = immutableMerge({}, merged, updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* This is a copy of the react-native style prop type, copied here to avoid RN dependencies for web clients
|
||||||
|
*/
|
||||||
|
type Falsy = undefined | null | false;
|
||||||
|
interface RecursiveArray<T> extends Array<T | RecursiveArray<T>> {}
|
||||||
|
/** Keep a brand of 'T' so that calls to `StyleSheet.flatten` can take `RegisteredStyle<T>` and return `T`. */
|
||||||
|
type RegisteredStyle<T> = number & { __registeredStyleBrand: T };
|
||||||
|
export type IStyleProp<T> = T | RegisteredStyle<T> | RecursiveArray<T | RegisteredStyle<T> | Falsy> | Falsy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to process a style and resolve any keys. The flattened style will be passed in. The function should
|
||||||
|
* return an object to merge with the prior style, keys that are specified but undefined will be deleted. If
|
||||||
|
* no changes need to be made an empty object or undefined should be returned.
|
||||||
|
*/
|
||||||
|
export type IFinalizeStyle = (target: object) => object | undefined;
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './Settings.types';
|
||||||
|
export * from './Settings';
|
||||||
|
export * from './Styles.types';
|
||||||
|
export * from './Styles';
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "lib"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
src
|
||||||
|
node_modules
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.editorconfig
|
||||||
|
config.js
|
||||||
|
jest.config.js
|
||||||
|
tslint.json
|
||||||
|
tsconfig.json
|
||||||
|
jsconfig.json
|
||||||
|
webpack.config.js
|
||||||
|
webpack.serve.config.js
|
||||||
|
*.build.log
|
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Загрузка…
Ссылка в новой задаче