From 1df690a96413e674fb9fee40f713b611a44c5a92 Mon Sep 17 00:00:00 2001 From: Roman Hotsiy Date: Fri, 16 Mar 2018 17:02:31 +0200 Subject: [PATCH] feat: more advanced theme engine --- package.json | 1 + src/common-elements/fields-layout.ts | 4 +- src/common-elements/fields.ts | 18 +-- src/common-elements/mixins.ts | 11 -- src/common-elements/panels.ts | 6 +- src/components/Redoc/elements.tsx | 12 +- src/components/Responses/styled.elements.ts | 4 +- .../SecuirityRequirement.tsx | 4 +- src/services/RedocNormalizedOptions.ts | 6 +- src/styled-components.ts | 12 +- src/theme.ts | 121 ++++++++++++++++-- src/utils/__tests__/styled.test.ts | 19 --- src/utils/index.ts | 1 - src/utils/styled.ts | 16 --- yarn.lock | 4 + 15 files changed, 154 insertions(+), 85 deletions(-) delete mode 100644 src/utils/__tests__/styled.test.ts delete mode 100644 src/utils/styled.ts diff --git a/package.json b/package.json index 3f4fa30568..371df457fd 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "mobx-react": "^4.3.3", "openapi-sampler": "1.0.0-beta.9", "perfect-scrollbar": "^1.3.0", + "polished": "^1.9.2", "prismjs": "^1.8.1", "prop-types": "^15.6.0", "react-dropdown": "^1.3.0", diff --git a/src/common-elements/fields-layout.ts b/src/common-elements/fields-layout.ts index 7646da613c..1ddb732a1d 100644 --- a/src/common-elements/fields-layout.ts +++ b/src/common-elements/fields-layout.ts @@ -1,12 +1,12 @@ import styled from '../styled-components'; -import { transparentizeHex } from '../utils/styled'; +import { transparentize } from 'polished'; import { deprecatedCss } from './mixins'; export const PropertiesTableCaption = styled.caption` text-align: right; font-size: 0.9em; font-weight: normal; - color: ${props => transparentizeHex(props.theme.colors.text, 0.4)}; + color: ${props => transparentize(0.4, props.theme.colors.text)}; `; export const PropertyCell = styled.td` diff --git a/src/common-elements/fields.ts b/src/common-elements/fields.ts index cbd639aa6f..2b12f3c98d 100644 --- a/src/common-elements/fields.ts +++ b/src/common-elements/fields.ts @@ -1,6 +1,6 @@ import styled from 'styled-components'; -import { transparentizeHex } from '../utils/styled'; import { PropertyNameCell } from './fields-layout'; +import { transparentize } from 'polished'; export const ClickablePropertyNameCell = PropertyNameCell.extend` cursor: pointer; @@ -13,14 +13,14 @@ export const FieldLabel = styled.span` `; export const TypePrefix = styled(FieldLabel)` - color: ${props => transparentizeHex(props.theme.colors.text, 0.4)}; + color: ${props => transparentize(0.4, props.theme.colors.text)}; `; export const TypeName = styled(FieldLabel)` - color: ${props => transparentizeHex(props.theme.colors.text, 0.8)}; + color: ${props => transparentize(0.8, props.theme.colors.text)}; `; export const TypeTitle = styled(FieldLabel)` - color: ${props => transparentizeHex(props.theme.colors.text, 0.5)}; + color: ${props => transparentize(0.5, props.theme.colors.text)}; `; export const TypeFormat = TypeName; @@ -55,13 +55,13 @@ export const PatternLabel = styled(FieldLabel)` export const ExampleValue = styled.span` font-family: ${props => props.theme.code.fontFamily}; - background-color: ${props => transparentizeHex(props.theme.colors.text, 0.02)}; - border: 1px solid ${props => transparentizeHex(props.theme.colors.text, 0.15)}; + background-color: ${props => transparentize(0.02, props.theme.colors.text)}; + border: 1px solid ${props => transparentize(0.15, props.theme.colors.text)}; margin: 0 3px; padding: 0.4em 0.2em 0.2em; font-size: 0.8em; border-radius: 2px; - color: ${props => transparentizeHex(props.theme.colors.text, 0.9)}; + color: ${props => transparentize(0.9, props.theme.colors.text)}; display: inline-block; min-width: 20px; text-align: center; @@ -70,8 +70,8 @@ export const ExampleValue = styled.span` `; export const ConstraintItem = styled(FieldLabel)` - background-color: ${props => transparentizeHex(props.theme.colors.main, 0.15)}; - color: ${props => transparentizeHex(props.theme.colors.main, 0.6)}; + background-color: ${props => transparentize(0.15, props.theme.colors.main)}; + color: ${props => transparentize(0.6, props.theme.colors.main)}; margin-right: 6px; margin-left: 6px; border-radius: 2px; diff --git a/src/common-elements/mixins.ts b/src/common-elements/mixins.ts index d4129c89dd..4c9d7803b8 100644 --- a/src/common-elements/mixins.ts +++ b/src/common-elements/mixins.ts @@ -4,14 +4,3 @@ export const deprecatedCss = css` text-decoration: line-through; color: #bdccd3; `; - -export const hoverColor = color => { - if (!color) { - return ''; - } - return css` - &:hover { - color: ${color}; - } - `; -}; diff --git a/src/common-elements/panels.ts b/src/common-elements/panels.ts index be6ff931af..4f66e09a63 100644 --- a/src/common-elements/panels.ts +++ b/src/common-elements/panels.ts @@ -1,7 +1,7 @@ import styled, { media } from '../styled-components'; export const MiddlePanel = styled.div` - width: ${props => 100 - props.theme.rightPanel.width}%; + width: calc(100% - ${props => props.theme.rightPanel.width}); padding: ${props => props.theme.spacingUnit * 2}px; ${media.lessThan('medium')` @@ -10,9 +10,9 @@ export const MiddlePanel = styled.div` `; export const RightPanel = styled.div` - width: ${props => props.theme.rightPanel.width}%; + width: ${props => props.theme.rightPanel.width}; color: #fafbfc; - bckground-color: ${props => props.theme.rightPanel.backgroundColor}; + background-color: ${props => props.theme.rightPanel.backgroundColor}; padding: ${props => props.theme.spacingUnit * 2}px; ${media.lessThan('medium')` diff --git a/src/components/Redoc/elements.tsx b/src/components/Redoc/elements.tsx index 3c1091c68b..ca08785c98 100644 --- a/src/components/Redoc/elements.tsx +++ b/src/components/Redoc/elements.tsx @@ -1,4 +1,3 @@ -import { hoverColor } from '../../common-elements/mixins'; import styled, { media } from '../../styled-components'; export { ClassAttributes } from 'react'; @@ -29,8 +28,15 @@ export const RedocWrap = styled.div` a { text-decoration: none; - color: ${props => props.theme.links.color || props.theme.colors.main}; - ${props => hoverColor(props.theme.links.hover)}; + color: ${props => props.theme.links.color}; + + &:visited { + color: ${props => props.theme.links.visited}; + } + + &:hover { + color: ${props => props.theme.links.hover}; + } } `; diff --git a/src/components/Responses/styled.elements.ts b/src/components/Responses/styled.elements.ts index 7f4c7eabf2..b4cf151ac3 100644 --- a/src/components/Responses/styled.elements.ts +++ b/src/components/Responses/styled.elements.ts @@ -1,7 +1,7 @@ import styled from '../../styled-components'; import { UnderlinedHeader } from '../../common-elements'; -import { transparentizeHex } from '../../utils'; +import { transparentize } from 'polished'; import { ResponseTitle } from './ResponseTitle'; export const StyledResponseTitle = styled(ResponseTitle)` @@ -13,7 +13,7 @@ export const StyledResponseTitle = styled(ResponseTitle)` cursor: pointer; color: ${props => props.theme.colors[props.type]}; - background-color: ${props => transparentizeHex(props.theme.colors[props.type], 0.08)}; + background-color: ${props => transparentize(0.08, props.theme.colors[props.type])}; ${props => (props.empty && diff --git a/src/components/SecurityRequirement/SecuirityRequirement.tsx b/src/components/SecurityRequirement/SecuirityRequirement.tsx index e170404f43..6c6d3ba0ea 100644 --- a/src/components/SecurityRequirement/SecuirityRequirement.tsx +++ b/src/components/SecurityRequirement/SecuirityRequirement.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import styled from '../../styled-components'; -import { transparentizeHex } from '../../utils/styled'; +import { transparentize } from 'polished'; import { UnderlinedHeader } from '../../common-elements/headers'; import { SecurityRequirementModel } from '../../services/models/SecurityRequirement'; @@ -8,7 +8,7 @@ import { SecurityRequirementModel } from '../../services/models/SecurityRequirem const ScopeName = styled.code` font-size: ${props => props.theme.code.fontSize}; font-family: ${props => props.theme.code.fontFamily}; - border: 1px solid ${props => transparentizeHex(props.theme.colors.text, 0.15)}; + border: 1px solid ${props => transparentize(0.15, props.theme.colors.text)}; margin: 0 3px; padding: 0.2em; display: inline-block; diff --git a/src/services/RedocNormalizedOptions.ts b/src/services/RedocNormalizedOptions.ts index ccca10a944..7ae0c3c04b 100644 --- a/src/services/RedocNormalizedOptions.ts +++ b/src/services/RedocNormalizedOptions.ts @@ -1,4 +1,4 @@ -import defaultTheme, { ThemeInterface } from '../theme'; +import defaultTheme, { ResolvedThemeInterface, ThemeInterface, resolveTheme } from '../theme'; import { querySelector } from '../utils/dom'; import { isNumeric, mergeObjects } from '../utils/helpers'; @@ -81,7 +81,7 @@ export class RedocNormalizedOptions { return () => 0; } - theme: ThemeInterface; + theme: ResolvedThemeInterface; scrollYOffset: () => number; hideHostname: boolean; expandResponses: { [code: string]: boolean } | 'all'; @@ -93,7 +93,7 @@ export class RedocNormalizedOptions { hideDownloadButton: boolean; constructor(raw: RedocRawOptions) { - this.theme = mergeObjects({} as any, defaultTheme, raw.theme || {}); + this.theme = resolveTheme(mergeObjects({} as any, defaultTheme, raw.theme || {})); this.scrollYOffset = RedocNormalizedOptions.normalizeScrollYOffset(raw.scrollYOffset); this.hideHostname = RedocNormalizedOptions.normalizeHideHostname(raw.hideHostname); this.expandResponses = RedocNormalizedOptions.normalizeExpandResponses(raw.expandResponses); diff --git a/src/styled-components.ts b/src/styled-components.ts index d08dd75585..b504ba4cfb 100644 --- a/src/styled-components.ts +++ b/src/styled-components.ts @@ -1,8 +1,8 @@ import * as styledComponents from 'styled-components'; -import { ThemeInterface } from './theme'; +import { ResolvedThemeInterface } from './theme'; -export type StyledFunction = styledComponents.ThemedStyledFunction; +export type StyledFunction = styledComponents.ThemedStyledFunction; function withProps( styledFunction: StyledFunction>, @@ -19,7 +19,7 @@ const { withTheme, } = (styledComponents as styledComponents.ThemedStyledComponentsModule< any ->) as styledComponents.ThemedStyledComponentsModule; +>) as styledComponents.ThemedStyledComponentsModule; export const media = { lessThan(breakpoint) { @@ -40,9 +40,9 @@ export const media = { between(firstBreakpoint, secondBreakpoint) { return (...args) => css` - @media (min-width: ${props => props.theme.breakpoints[firstBreakpoint]}) and (max-width: ${props => props.theme.breakpoints[ - secondBreakpoint - ]}) { + @media (min-width: ${props => + props.theme.breakpoints[firstBreakpoint]}) and (max-width: ${props => + props.theme.breakpoints[secondBreakpoint]}) { ${(css as any)(...args)}; } `; diff --git a/src/theme.ts b/src/theme.ts index 5f7a4ea82e..cf46f5311f 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -1,4 +1,6 @@ -const theme = { +import { lighten } from 'polished'; + +const theme: ThemeInterface = { spacingUnit: 20, breakpoints: { small: '50rem', @@ -8,9 +10,9 @@ const theme = { colors: { main: '#32329f', success: '#00aa13', - redirect: 'orange', + redirect: '#ffa500', error: '#e53935', - info: 'skyblue', + info: '#87ceeb', text: '#263238', warning: '#f1c400', http: { @@ -44,9 +46,9 @@ const theme = { fontFamily: 'Courier, monospace', }, links: { - color: undefined, // by default main color - visited: undefined, // by default main color - hover: undefined, // by default main color + color: ({ colors }) => colors.main, + visited: ({ colors }) => colors.main, + hover: ({ colors }) => lighten(0.2, colors.main), }, menu: { width: '260px', @@ -58,10 +60,113 @@ const theme = { }, rightPanel: { backgroundColor: '#263238', - width: 40, + width: '40%', }, }; export default theme; -export type ThemeInterface = typeof theme; +export function resolveTheme(theme: ThemeInterface): ResolvedThemeInterface { + const resolvedValues = {}; + let counter = 0; + const setProxy = (obj, path: string) => { + Object.keys(obj).forEach(k => { + const currentPath = (path ? path + '.' : '') + k; + const val = obj[k]; + if (typeof val === 'function') { + Object.defineProperty(obj, k, { + get() { + if (!resolvedValues[currentPath]) { + counter++; + if (counter > 1000) { + throw new Error( + `Theme probably contains cirucal dependency at ${currentPath}: ${val.toString()}`, + ); + } + + resolvedValues[currentPath] = val(theme); + } + return resolvedValues[currentPath]; + }, + enumerable: true, + }); + } else if (typeof val === 'object') { + setProxy(val, currentPath); + } + }); + }; + + setProxy(theme, ''); + return JSON.parse(JSON.stringify(theme)); +} + +export interface ResolvedThemeInterface { + spacingUnit: number; + breakpoints: { + small: string; + medium: string; + large: string; + }; + colors: { + main: string; + success: string; + redirect: string; + error: string; + info: string; + text: string; + warning: string; + http: { + get: string; + post: string; + put: string; + options: string; + patch: string; + delete: string; + basic: string; + link: string; + }; + }; + schemaView: { + linesColor: string; + defaultDetailsWidth: string; + }; + baseFont: { + size: string; + lineHeight: string; + weight: string; + family: string; + smoothing: string; + optimizeSpeed: boolean; + }; + headingsFont: { + family: string; + }; + code: { + fontSize: string; + fontFamily: string; + }; + links: { + color: string; + visited: string; + hover: string; + }; + menu: { + width: string; + backgroundColor: string; + }; + logo: { + maxHeight: string; + width: string; + }; + rightPanel: { + backgroundColor: string; + width: string; + }; +} + +export type primitive = string | number | boolean | undefined | null; +export type AdvancedThemeDeep = T extends primitive + ? T | ((theme: ResolvedThemeInterface) => T) + : AdvancedThemeObject; +export type AdvancedThemeObject = { [P in keyof T]: AdvancedThemeDeep }; +export type ThemeInterface = AdvancedThemeObject; diff --git a/src/utils/__tests__/styled.test.ts b/src/utils/__tests__/styled.test.ts deleted file mode 100644 index 630bf950a3..0000000000 --- a/src/utils/__tests__/styled.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as react from 'React'; -import { transparentizeHex } from '../styled'; - -describe('transparentizeHex', () => { - test('simple transparentize', () => { - const res = transparentizeHex('#000000', 0.5); - expect(res).toBe('rgba(0, 0, 0, 0.5)'); - }); - - test('transparentize hex shorthand', () => { - const res = transparentizeHex('#123', 0.5); - expect(res).toBe('rgba(17, 34, 51, 0.5)'); - }); - - test('do not transparentize (withot last param)', () => { - const res = transparentizeHex('#010203'); - expect(res).toBe('rgb(1, 2, 3)'); - }); -}); diff --git a/src/utils/index.ts b/src/utils/index.ts index 21bbc5fa24..846494fa3f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,4 @@ export * from './JsonPointer'; -export * from './styled'; export * from './openapi'; export * from './helpers'; diff --git a/src/utils/styled.ts b/src/utils/styled.ts deleted file mode 100644 index fae257a575..0000000000 --- a/src/utils/styled.ts +++ /dev/null @@ -1,16 +0,0 @@ -export function hexToRgb(hex: string): { r: number; g: number; b: number } { - hex = hex.replace('#', ''); - const r = parseInt(hex.length === 3 ? hex.slice(0, 1).repeat(2) : hex.slice(0, 2), 16); - const g = parseInt(hex.length === 3 ? hex.slice(1, 2).repeat(2) : hex.slice(2, 4), 16); - const b = parseInt(hex.length === 3 ? hex.slice(2, 3).repeat(2) : hex.slice(4, 6), 16); - return { r, g, b }; -} - -export function transparentizeHex(hex: string, alpha?: number): string { - const { r, g, b } = hexToRgb(hex); - if (alpha !== undefined) { - return `rgba(${r}, ${g}, ${b}, ${alpha})`; - } else { - return `rgb(${r}, ${g}, ${b})`; - } -} diff --git a/yarn.lock b/yarn.lock index eec9edbfd2..67048dc576 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5972,6 +5972,10 @@ pn@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" +polished@^1.9.2: + version "1.9.2" + resolved "https://registry.yarnpkg.com/polished/-/polished-1.9.2.tgz#d705cac66f3a3ed1bd38aad863e2c1e269baf6b6" + portfinder@^1.0.9: version "1.0.13" resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.13.tgz#bb32ecd87c27104ae6ee44b5a3ccbf0ebb1aede9"