├── .gitignore ├── .npmignore ├── README.md ├── component ├── README.md ├── __snapshots__ │ └── component.test.tsx.snap ├── component.test.tsx ├── component.ts └── component.types.ts ├── core.test.ts ├── core.ts ├── core.types.ts ├── deps ├── README.md ├── __snapshots__ │ └── deps.test.tsx.snap ├── deps.test.tsx ├── deps.ts ├── deps.types.ts └── index.ts ├── env ├── env.ts └── env.types.ts ├── index.ts ├── jest.config.js ├── package.json ├── theme ├── README.md ├── __snapshots__ │ └── theme.test.tsx.snap ├── index.ts ├── theme.convert.ts ├── theme.styler.ts ├── theme.test.tsx ├── theme.tsx └── theme.types.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # NMPy 61 | .npmyrc 62 | 63 | # Distributive 64 | dist 65 | compiled 66 | package-lock.json -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .jest 2 | .storybook 3 | node_modules 4 | 5 | # Ignore the .ts files 6 | **/*.ts 7 | **/*.tsx 8 | 9 | # Revert the .d.ts files 10 | !**/*.d.ts 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | @truekit/core 2 | ------------- 3 | The CORE of TrueKit™ 4 | 5 | ```sh 6 | npm i --save-dev @truekit/core 7 | ``` 8 | 9 | --- 10 | 11 | 12 | 13 | --- 14 | 15 | ### Будем честным, смотреть тут что-либо опасно ⚠️ 16 | Но зато можно почитать: 17 | 18 | - UIKit, который точно сможет. 🤦🏻‍♂️🤦🏻‍♀️ (Решение) 19 | - 🧰 TrueKit — 🐳, который сможет! (Бренд) 20 | - 🐳 TrueKit. Часть I. Темизация 👨🏻‍🎨. 21 | - 🐳 TrueKit. Часть II. Зависимости 💉 22 | - 🐳 TrueKit. Часть III. Слоты 🎰. 23 | 24 | --- 25 | 26 | ### Modules 27 | 28 | - [deps](./deps/) — Describing and resolving dependencies of component (aka DI) 29 | - [theme](./theme/) — Theme in JS 30 | -------------------------------------------------------------------------------- /component/README.md: -------------------------------------------------------------------------------- 1 | @truekit/core: component 2 | ------------------------ 3 | Всё ниже перечисленое очень и очень мутный черновик, в особенности всё что связанно ср `state`. 4 | 5 | ```tsx 6 | // LoginForm.types.ts 7 | import { Theme, createComponentDescriptor } from '@truekit/core'; 8 | 9 | export const $LoginForm = createComponentDescriptor( 10 | '@MyApp/LoginForm', 11 | {} as LoginFormProps, 12 | { 13 | Input: $Input, 14 | Button: $Button, 15 | Icon: $Icon.optional(), 16 | }, 17 | ); 18 | 19 | export type LoginFormProps = { 20 | action: string; 21 | deps?: Deps; 22 | theme?: Theme<{...}>; 23 | state?: State; 24 | header: SlotProp; 25 | content: SlotProp; 26 | license: Slot<(value: {agree: boolean; confirm: () => void}) => SlotContent>; 27 | } 28 | 29 | // LoginForm.tsx 30 | import { jsx, createComponent } from '@truekit/core/component'; 31 | import { $LoginForm } from './LoginForm.types'; 32 | 33 | 34 | export const LoginState = createState( 35 | // Scheme & defaults 36 | () => ({ 37 | agree: [true, null], 38 | }), 39 | 40 | // Mutations 41 | { 42 | confirm: ({agree}) => ({agree: !agree}), 43 | }, 44 | ); 45 | 46 | 47 | export const LoginForm = $LoginForm.createComponent( 48 | { 49 | defaultState, 50 | }, 51 | ( 52 | jsx, 53 | {action}, // Props 54 | {theme, Slot, state}, // Env 55 | {Icon}, // Deps 56 | ) => { 57 | const hostTheme = theme.for('host').set('type', type); 58 | 59 | return jsx('form', hostTheme.toDOMProps({action}), <> 60 | 61 | {value} 62 | {({agree, confirm}) => ( 66 | 67 | )} 68 | ); 69 | }, 70 | ); 71 | 72 | bla-bla} 74 | content={null} 75 | license={(parent, value) => <> 76 | {parent(value)} 77 |
78 | И ещё вы соглашатесь на боль. 79 | } 80 | /> 81 | 82 | 83 | // Usage 84 | import { createDepsRegistry, createThemeRegistry } from '@truekit/cor'; 85 | import { $Icon, Icon } from './components/Icon'; 86 | import { $LoginForm, LoginForm } from './components/LoginForm'; 87 | 88 | const depsInjection = $LoginForm.createDeps({ 89 | [$Icon.id]: Icon, 90 | }); 91 | 92 | const themesInjection = $LoginForm.createTheme({ 93 | host: {}, 94 | elements: {}, 95 | }, { 96 | [$Icon.id]: $Icon.createTheme({ 97 | host: {}, 98 | elements: {}, 99 | }), 100 | }); 101 | 102 | ReactDOM.render( 103 | 104 | 105 | 106 | 107 | , 108 | document.getElementById('root'), 109 | ); 110 | ``` 111 | 112 | 113 | ---- 114 | 115 | 116 | ### Examples 117 | 118 | #### Counter 119 | 120 | ```tsx 121 | export const CounterState = createState( 122 | // Initial State 123 | () => ({ 124 | value: 0 125 | }), 126 | 127 | // Mutations 128 | { 129 | reset: () => ({value: 0}), 130 | increment: (state) => ({value: state().value + 1}), 131 | decrement: (state) => ({value: state().value - 1}), 132 | 133 | asyncRandom: (state, transation) => 134 | transation(() => new Promise(resolve => { 135 | setTimeout(() => resolve({ 136 | value: state().value % 10, 137 | }), 1500); 138 | })) 139 | .then(() => { 140 | console.log('Транзакция прошла успешно'); 141 | }) 142 | .catch((reason) => { 143 | console.log('Состояние мутируемых полей изменилось, либо просто fail'); 144 | throw reason; 145 | }) 146 | , 147 | }, 148 | 149 | // Reactions 150 | [ 151 | navState.reaction('navigate', ($, payload) => $.reset()), 152 | ], 153 | ); 154 | 155 | export const Counter = $Counter.createComponent( 156 | { 157 | defaultState: CounterState, 158 | }, 159 | (jsx, {}, {theme, state}) => ( 160 | jsx('div', theme.for('host').toDOMProps(), <> 161 | "`; 4 | 5 | exports[`component Btn with deps overrides 1`] = ` 6 | "default 7 |
8 | over-1 9 |
10 | over-2 11 | over-3 12 |
13 |
" 14 | `; 15 | 16 | exports[`component Btn with inline deps and inline root theme 1`] = `""`; 17 | 18 | exports[`component Btn with inline deps and without theme 1`] = `""`; 19 | 20 | exports[`component Btn without theme and deps 1`] = ` 21 | "" 24 | `; 25 | 26 | exports[`component slots default slots 1`] = ` 27 | "
28 | 29 |
num: 123
30 |
val: wow
31 |
fragment: bold!
32 |
33 | callback: 34 |

Hi, RubaXa!

35 |
36 |
" 37 | `; 38 | 39 | exports[`component slots override num and str slot 1`] = ` 40 | "
41 | 42 |
num: 321
43 |
val:
44 |
fragment: bold!
45 |
46 | callback: 47 |

Hi, RubaXa!

48 |
49 |
" 50 | `; 51 | 52 | exports[`component slots override slot with parent 1`] = ` 53 | "
54 | 55 |
num: 246
56 |
val: wow
57 |
fragment: bold!
58 |
59 | callback: 60 |

Hi, RubaXa!


Yes, RubaXa. 61 |
62 |
" 63 | `; 64 | 65 | exports[`component slots slot with is defaults 1`] = ` 66 | "

Primary

67 | Sub 68 |

69 | " 70 | `; 71 | 72 | exports[`component slots slot with is header is null 1`] = ` 73 | " 74 | Sub 75 |

76 | " 77 | `; 78 | 79 | exports[`component slots slot with is header is undefined 1`] = ` 80 | "

Primary

81 | Sub 82 |

83 | " 84 | `; 85 | 86 | exports[`component slots slot with is lastHeaderEl is h3 1`] = ` 87 | "

Primary

88 | Sub 89 |

90 | " 91 | `; 92 | 93 | exports[`component slots slot with is lastHeaderEl is null 1`] = ` 94 | "

Primary

95 | Sub 96 | 97 | " 98 | `; 99 | 100 | exports[`component slots slot with is lastHeaderEl is undefined 1`] = ` 101 | "

Primary

102 | Sub 103 |

104 | " 105 | `; 106 | 107 | exports[`component slots slot with is subHeaderEl is h2 1`] = ` 108 | "

Primary

109 |

Sub

110 |

111 | " 112 | `; 113 | 114 | exports[`component slots slot with is subHeaderEl is h2 with override 1`] = ` 115 | "

Primary

116 |

Wow!

117 |

118 | " 119 | `; 120 | 121 | exports[`component slots slots with nested 1`] = ` 122 | "
123 | 124 |
125 | 126 |
num: 369
127 |
val: wow
128 |
fragment: bold!
129 |
130 | callback: 131 | 132 |
133 |
134 | --- 135 |
136 | 137 |
num: 492
138 |
val: wow
139 |
fragment: bold!
140 |
141 | callback: 142 | 143 |
144 |
145 | 146 |
num: 246
147 |
val: wow
148 |
fragment: bold!
149 |
150 | callback: 151 |

Hi, RubaXa!

152 |
153 |
" 154 | `; 155 | -------------------------------------------------------------------------------- /component/component.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; // @todo: Переехать на `jsx` 2 | import * as ReactDOM from 'react-dom'; 3 | 4 | import { createComponentDescriptor } from './component'; 5 | import { 6 | Deps, 7 | DepsProvider, 8 | createDepsRegistry, 9 | createDeps, 10 | createDepOverride, 11 | } from '../deps'; 12 | import { Theme } from '../theme'; 13 | import { 14 | createThemeRegistryFor, 15 | ThemeProvider, 16 | createThemeRegistry, 17 | } from '../theme/theme'; 18 | import { resetCSS } from '@artifact-project/css'; 19 | import { SlotContent, SlotProp, SlotElement } from './component.types'; 20 | 21 | const $Icon = createComponentDescriptor('@truekit/core/cmp: Icon', {} as IconProps, {}); 22 | const $Btn = createComponentDescriptor('@truekit/core/cmp: Btn', {} as BtnProps, { 23 | Icon: $Icon, 24 | }); 25 | const $Card = createComponentDescriptor('@truekit/core/cmp: Card', {} as CardProps, {}); 26 | 27 | type IconProps = { 28 | name: string; 29 | theme?: Theme<{ 30 | host: {}; 31 | elements: {}; 32 | }>; 33 | } 34 | 35 | type BtnProps = { 36 | type: 'text' | 'password'; 37 | value: string; 38 | theme?: Theme<{ 39 | host: Pick; 40 | elements: {value: true}; 41 | }>; 42 | deps?: Deps; 43 | } 44 | 45 | type CardProps = { 46 | children: SlotContent; 47 | } 48 | 49 | const Icon = $Icon.createComponent((jsx, {name}, {theme}) => { 50 | const hostTheme = theme.for('host'); 51 | return jsx('i', hostTheme.toDOMProps(), <>{name}); 52 | }); 53 | 54 | const EmIcon = $Icon.createComponent((jsx, {name}, {theme}) => { 55 | const hostTheme = theme.for('host'); 56 | return jsx('em', hostTheme.toDOMProps(), <>{name}); 57 | }); 58 | 59 | const StrongIcon = $Icon.createComponent((jsx, {name}, {theme}) => { 60 | const hostTheme = theme.for('host'); 61 | return jsx('strong', hostTheme.toDOMProps(), <>{name}); 62 | }); 63 | 64 | const SpanIcon = $Icon.createComponent((jsx, {name}, {theme}) => { 65 | const hostTheme = theme.for('host'); 66 | return jsx('span', hostTheme.toDOMProps(), <>{name}); 67 | }); 68 | 69 | const Btn = $Btn.createComponent((jsx, {type, value}, {theme}, {Icon}) => { 70 | const hostTheme = theme.for('host').set('type', type); 71 | 72 | return jsx('button', hostTheme.toDOMProps({type}), <> 73 | 74 | {value} 75 | ); 76 | }); 77 | 78 | const Card = $Card.createComponent((jsx, {children}, {}) => { 79 | return jsx('div', {}, children); 80 | }); 81 | 82 | let btnTheme: NonNullable; 83 | let iconTheme: NonNullable; 84 | 85 | beforeAll(() => { 86 | process.env.NODE_ENV = 'production'; 87 | resetCSS(0); 88 | 89 | btnTheme = $Btn.createTheme({ 90 | host: {color: 'black', ':modifiers': {type: {password: {color: 'red'}}}}, 91 | elements: {value: {color: '#333'}}, 92 | }); 93 | 94 | iconTheme = $Icon.createTheme({ 95 | host: {color: 'blue'}, 96 | elements: {}, 97 | }); 98 | }); 99 | 100 | describe('component', () => { 101 | it('Btn without theme and deps', () => { 102 | expect(render()).toMatchSnapshot(); 103 | }); 104 | 105 | it('Btn with inline deps and without theme', () => { 106 | expect(render( 110 | )).toMatchSnapshot(); 111 | }); 112 | 113 | it('Btn with inline deps and inline root theme', () => { 114 | expect(render( 119 | )).toMatchSnapshot(); 120 | }); 121 | 122 | it('Btn with all deps and themes', () => { 123 | const deps = $Btn.createStrictDeps({ 124 | [$Icon.id]: Icon, 125 | }); 126 | const allDeps = createDepsRegistry([deps]); 127 | const allThemes = createThemeRegistryFor($Btn, { 128 | [$Btn.id]: btnTheme, 129 | [$Icon.id]: iconTheme, 130 | }); 131 | 132 | expect(render( 133 | 134 | 135 | 136 | 137 | 138 | )).toMatchSnapshot(); 139 | }); 140 | 141 | it('Btn with deps overrides', () => { 142 | const defaultDeps = createDeps({[$Icon.id]: Icon}); 143 | const iconOver1 = createDepOverride($Icon, [$Card])(EmIcon); 144 | const iconOver2 = createDepOverride($Icon, [$Card], {name: 'over-2'})(StrongIcon); 145 | const iconOver3 = createDepOverride($Icon, [$Card, $Card])(SpanIcon); 146 | 147 | expect(render( 148 | 149 | {'\n'} 150 | {'\n\t'} 151 | {'\n\t'} 152 | {'\n\t\t'} 153 | {'\n\t\t'} 154 | {'\n\t'} 155 | {'\n'} 156 | 157 | 158 | )).toMatchSnapshot(); 159 | }); 160 | }); 161 | 162 | describe('component slots', () => { 163 | type BlockProps = { 164 | children?: SlotProp; 165 | theme?: Theme<{ 166 | host: {}, 167 | elements: {}, 168 | }>; 169 | value?: string; 170 | data?: object; 171 | slotNum?: SlotProp; 172 | slotStr?: SlotProp; 173 | slotFragment?: SlotProp; 174 | slotWithValue?: SlotProp<(value: {username:string}) => SlotContent>; 175 | } 176 | 177 | const $Block = createComponentDescriptor('@truekit/core/cmp: Block', {} as BlockProps); 178 | const Block = $Block.createComponent((jsx, {}, {Slot, theme}) => { 179 | return ( 180 |
{'\n\t'} 181 | {'\n\t'} 182 |
num: {123}
{'\n\t'} 183 |
val: wow
{'\n\t'} 184 |
fragment: bold!
{'\n\t'} 185 |
{'\n\t\t'} 186 | callback:{'\n\t\t'} 187 | 188 | {({username}) =>

Hi, {username}!

} 189 |
{'\n\t'} 190 |
{'\n'} 191 |
192 | ); 193 | }); 194 | 195 | it('default slots', () => { 196 | expect(render()).toMatchSnapshot(); 197 | }); 198 | 199 | it('override num and str slot', () => { 200 | expect(render()).toMatchSnapshot(); 204 | }); 205 | 206 | it('override slot with parent', () => { 207 | expect(render( parent * 2} 209 | slotWithValue={(parent, value) => <>{parent(value)}
Yes, {value.username}.} 210 | />)).toMatchSnapshot(); 211 | }); 212 | 213 | describe('slot with is', () => { 214 | const $WithIS = createComponentDescriptor('@truekit/core/cmp: WithIS', {} as { 215 | header?: SlotProp< SlotContent >; 216 | subHeader?: SlotProp< SlotContent >; 217 | subHeaderEl?: SlotElement; 218 | lastHeader?: SlotProp< SlotContent >; 219 | lastHeaderEl?: SlotElement; 220 | }); 221 | const WithIS = $WithIS.createComponent((_, props, {Slot}) => <> 222 | }>Primary{'\n'} 223 | Sub{'\n'} 224 | ] }/>{'\n'} 225 | ); 226 | 227 | it('defaults', () => { 228 | expect(render()).toMatchSnapshot(); 229 | }); 230 | 231 | it('header is null', () => { 232 | expect(render()).toMatchSnapshot(); 233 | }); 234 | 235 | it('header is undefined', () => { 236 | expect(render()).toMatchSnapshot(); 237 | }); 238 | 239 | it('subHeaderEl is h2', () => { 240 | expect(render(} />)).toMatchSnapshot(); 241 | }); 242 | 243 | it('subHeaderEl is h2 with override', () => { 244 | expect(render(Wow!} subHeaderEl={

} />)).toMatchSnapshot(); 245 | }); 246 | 247 | it('lastHeaderEl is h3', () => { 248 | expect(render(} />)).toMatchSnapshot(); 249 | }); 250 | 251 | it('lastHeaderEl is null', () => { 252 | expect(render()).toMatchSnapshot(); 253 | }); 254 | 255 | it('lastHeaderEl is undefined', () => { 256 | expect(render()).toMatchSnapshot(); 257 | }); 258 | }); 259 | 260 | it('slots with nested', () => { 261 | const redTheme = $Block.createTheme({ 262 | host: {color: 'red'}, 263 | elements: {}, 264 | }); 265 | const greenTheme = $Block.createTheme({ 266 | host: {color: 'green'}, 267 | elements: {}, 268 | }); 269 | const blueTheme = $Block.createTheme({ 270 | host: {color: 'blue'}, 271 | elements: {}, 272 | }); 273 | 274 | expect(render( 275 | 276 | parent * 2} theme={redTheme}>{'\n\t'} 277 | parent * 3} slotWithValue={null} theme={greenTheme}/>{'\n\t'} 278 | ---{'\n\t'} 279 | parent * 4} slotWithValue={null}/>{'\n\t'} 280 | 281 | 282 | )).toMatchSnapshot(); 283 | }); 284 | }); 285 | 286 | const root = document.createElement('div'); 287 | 288 | function render(fragment: JSX.Element) { 289 | ReactDOM.render(fragment, root); 290 | return root.innerHTML; 291 | } 292 | -------------------------------------------------------------------------------- /component/component.ts: -------------------------------------------------------------------------------- 1 | import { createElement, ReactElement, cloneElement } from 'react'; 2 | import { ComponentDescriptor, ComponentRender, SlotElement, SlotContent } from './component.types'; 3 | import { createDescriptor, getDescriptorOverride } from '../core'; 4 | import { 5 | createDepsDescriptor, 6 | createDepsBy, 7 | createStrictDepsBy, 8 | } from '../deps'; 9 | import { DescriptorWithMetaMap } from '../core.types'; 10 | import { withEnvScope, getActiveEnvScope, getEnvContext } from '../env/env'; 11 | import { useTheme, createThemeFor } from '../theme'; 12 | import { EnvContextProps } from '../env/env.types'; 13 | 14 | export function createComponentDescriptor< 15 | N extends string, 16 | P extends object, 17 | DM extends DescriptorWithMetaMap, 18 | >( 19 | name: N, 20 | props: P, 21 | deps?: DM, 22 | ): ComponentDescriptor { 23 | const $descriptor = createDescriptor(name).withMeta

() as ComponentDescriptor; 24 | const $deps = createDepsDescriptor($descriptor, Object(deps)); 25 | 26 | $descriptor.deps = $deps; 27 | $descriptor.props = props; 28 | 29 | $descriptor.createComponent = (render) => createComponent($descriptor, render); 30 | $descriptor.createTheme = (rules) => createThemeFor($descriptor, rules); 31 | $descriptor.createDeps = (deps) => createDepsBy($deps, deps as any); 32 | $descriptor.createStrictDeps = (deps) => createStrictDepsBy($deps, deps as any); 33 | 34 | return $descriptor; 35 | } 36 | 37 | export function createComponent< 38 | D extends ComponentDescriptor, 39 | >( 40 | $descriptor: D, 41 | render: ComponentRender, 42 | ): (props: D['meta']) => ReactElement { 43 | const $deps = $descriptor.deps; 44 | 45 | function Component(props: D['meta']) { 46 | const entry: EnvContextProps = { 47 | deps: null, 48 | theme: null, 49 | depsInjection: null, 50 | props, 51 | }; 52 | 53 | return withEnvScope($descriptor, entry, () => { 54 | const ctx = getEnvContext(); 55 | const overrides = ctx !== null && ctx.deps !== null ? ctx.deps.overrides : null; 56 | 57 | if (overrides !== null && overrides.has($descriptor)) { 58 | const override = getDescriptorOverride(overrides.get($descriptor)!, ctx!); 59 | 60 | if (override !== null && override.value !== Component) { 61 | return override.value(props); 62 | } 63 | } 64 | 65 | const theme = useTheme($descriptor, props, ctx); 66 | const deps = $deps.use(props, ctx); 67 | 68 | return render( 69 | createElement, 70 | props, 71 | {theme, Slot} as any, 72 | deps as any, 73 | ); 74 | }); 75 | } 76 | 77 | Component.$descriptor = $descriptor; 78 | Component.displayName = $descriptor.name; 79 | 80 | return Component; 81 | } 82 | 83 | type SlotProps = { 84 | name: string; 85 | value: any; 86 | children: React.ReactNode; 87 | is?: SlotElement | [SlotElement, SlotElement]; 88 | } 89 | 90 | function Slot({name, children, value, is}: SlotProps) { 91 | if (name == null) { 92 | name = 'children'; 93 | } 94 | 95 | const envScope = getActiveEnvScope(); 96 | const scopeProps = (envScope !== null && envScope.ctx !== null) ? envScope.ctx.props : null; 97 | const content = scopeProps != null ? scopeProps[name] : undefined; 98 | 99 | if (is) { 100 | if (!is.hasOwnProperty('type')) { 101 | is = is[0] === null ? null : (is[0] || is[1] || null); 102 | } 103 | } else { 104 | is = null; 105 | } 106 | 107 | // Слот не нужен, так сказали свыше! 108 | if (content === null) { 109 | return null; 110 | } 111 | 112 | // Переопределение слота 113 | if (content !== undefined) { 114 | // Это перегрузка 115 | if (typeof content === 'function') { 116 | const result = content(children, value); 117 | 118 | // Слот не нужен 119 | if (result === null) { 120 | return null; 121 | } else if (result !== undefined) { 122 | return wrap(result, is); 123 | } 124 | } else { 125 | return wrap(content, is); 126 | } 127 | } 128 | 129 | // Значение есть, но оно именно null 130 | if (children === null) { 131 | return null; 132 | } else if (children === undefined) { 133 | return is; // возвращаем оборачиващий элемент 134 | } 135 | 136 | if (typeof children === 'function') { 137 | return wrap(children(value), is); 138 | } 139 | 140 | return wrap(children, is); 141 | } 142 | 143 | function wrap(content: any, is: any) { 144 | if (is !== null) { 145 | content = content && cloneElement( 146 | is as React.ReactElement, 147 | is.props, 148 | content, 149 | ); 150 | } 151 | 152 | return content; 153 | } 154 | -------------------------------------------------------------------------------- /component/component.types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createElement, 3 | } from 'react'; 4 | import { 5 | DescriptorWithMeta, 6 | DescriptorWithMetaMap, 7 | LikeFragment, 8 | LikeComponent, 9 | ToIntersect, 10 | CleanObject, 11 | Meta, 12 | GetMeta, 13 | } from '../core.types'; 14 | import { 15 | DepsDescriptor, 16 | DepsMap, 17 | DepsMapBy, 18 | } from '../deps'; 19 | import { 20 | Theme, 21 | ThemeRules, 22 | } from '../theme/theme.types'; 23 | 24 | export interface ComponentDescriptor< 25 | N extends string, 26 | P extends object, 27 | DM extends DescriptorWithMetaMap, 28 | > extends DescriptorWithMeta { 29 | deps: DepsDescriptor; 30 | props: P; 31 | 32 | createComponent(render: ComponentRender): LikeComponent; 33 | createTheme>(rules: ThemeRules): Theme; 34 | createDeps(deps: Partial>): DepsMap 35 | createStrictDeps(deps: DepsMapBy): DepsMap; 36 | } 37 | 38 | type GetTheme

= P extends {theme?: infer P} ? P : never; 39 | type GetThemeSpec

= P extends {theme?: Theme} ? TS : never; 40 | type GetDeps

= P extends {deps?: infer D} ? D : never; 41 | 42 | export type SlotProp = null | (S extends (value: infer V) => infer R 43 | ? SlotWithValue | R 44 | : SlotWithoutValue | S 45 | ) | Meta 46 | 47 | type SlotWithValue = (parent: S, value: V) => R 48 | type SlotWithoutValue = (parent: S) => S 49 | 50 | type SlotPropTypeInfer

= ToIntersect

extends Meta ? S : never; 51 | 52 | export type GetSlotsSpec

= CleanObject<{ 53 | [K in keyof P]-?: SlotPropTypeInfer; 54 | }> 55 | 56 | type SlotComponentProps = ( 57 | N extends 'children' 58 | ? {name?: N} 59 | : {name: N} 60 | ) & ( 61 | T extends (value: infer V) => any 62 | ? {value: V; children?: T; } // {(value) => ...} 63 | : {children?: T;} // ... 64 | ) & ({ 65 | is?: SlotElement | [SlotElement | undefined, SlotElement | undefined]; 66 | }) 67 | 68 | export type SlotComponent = ( 69 | (props: { 70 | [N in keyof S]: SlotComponentProps; 71 | }[keyof S]) => LikeFragment 72 | ); 73 | 74 | export type ComponentRender> = ( 75 | jsx: typeof createElement, 76 | props: D['meta'], 77 | env: { 78 | theme: GetTheme; 79 | Slot: SlotComponent>; 80 | }, 81 | deps: GetDeps, 82 | ) => LikeFragment; 83 | 84 | type SlotValue = number | string | JSX.Element; 85 | 86 | export type SlotContent = SlotValue | SlotValue[]; 87 | export type SlotPropType = SlotContent | ((value: object) => SlotContent); 88 | 89 | export type SlotElement = null | { 90 | type: any; 91 | props: object; 92 | } -------------------------------------------------------------------------------- /core.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createDescriptor, 3 | } from './core'; 4 | 5 | it('createDescriptor', () => { 6 | const $desc = createDescriptor('uniq'); 7 | expect($desc.id).toBe('uniq'); 8 | expect($desc.id).toBe($desc.name); 9 | }); 10 | -------------------------------------------------------------------------------- /core.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Descriptor, 3 | DescriptorWith, 4 | DescriptorWithMetaMap, 5 | DescriptorWithMeta, 6 | Predicate, 7 | DescriptorOverride, 8 | PredicateFunc, 9 | DescriptorOverrideIndex, 10 | } from './core.types'; 11 | import { EnvContextEntry } from './env/env.types'; 12 | import { getActiveEnvScope } from './env/env'; 13 | 14 | const reservedDescriptors = {} as { 15 | [name:string]: Descriptor; 16 | }; 17 | 18 | export function optional(v: T): T | undefined { 19 | return v; 20 | } 21 | 22 | /** @param name — must be unique string constant (don't use interpolation or concatenation) */ 23 | export function createDescriptor(name: N): DescriptorWith { 24 | const descriptor: DescriptorWith = { 25 | id: name, 26 | name, 27 | isOptional: false, 28 | optional() { 29 | return Object.create(this, {isOptional: {value: true}}); 30 | }, 31 | withMeta: () => descriptor as any, 32 | }; 33 | 34 | if (reservedDescriptors.hasOwnProperty(name)) { 35 | throw new Error(`[@truekit/core] Cannot redeclare descriptor '${name}'`); 36 | } 37 | 38 | reservedDescriptors[name] = descriptor; 39 | 40 | return descriptor; 41 | } 42 | 43 | export function createDescriptorWithMetaMap(map: T): T { 44 | return map; 45 | } 46 | 47 | export function createPredicate(predicate?: Predicate): PredicateFunc { 48 | let fn: PredicateFunc = null; 49 | 50 | if (typeof predicate === 'function') { 51 | fn = predicate; 52 | } else if (predicate != null) { 53 | const keys = Object.keys(predicate); 54 | const length = keys.length; 55 | 56 | fn = (props: T) => { 57 | let idx = length; 58 | while (idx--) { 59 | const key = keys[idx] 60 | if (props[key] !== predicate[key]) { 61 | return false; 62 | } 63 | } 64 | return true; 65 | }; 66 | } 67 | 68 | return fn; 69 | } 70 | 71 | export function createDescriptorOverride< 72 | D extends DescriptorWithMeta 73 | >( 74 | Target: D, 75 | specificPath: DescriptorWithMeta[], 76 | predicate?: Predicate, 77 | ): DescriptorOverride { 78 | return { 79 | xpath: specificPath.concat(Target), 80 | predicate: createPredicate(predicate), 81 | }; 82 | } 83 | 84 | export function createDescriptorOverrideIndex< 85 | T extends DescriptorOverride, 86 | >(overrides: T[]): DescriptorOverrideIndex { 87 | return overrides.reduce((index, override) => { 88 | const xpath = override.xpath.slice(); 89 | const target = xpath.pop(); 90 | 91 | index.set( 92 | target, 93 | (index.get(target) || []) 94 | .concat({ 95 | ...override, 96 | xpath, 97 | }) 98 | .sort(descriptorOverrideComparator) 99 | ); 100 | 101 | return index; 102 | }, new Map); 103 | } 104 | 105 | function descriptorOverrideComparator(a: DescriptorOverride, b: DescriptorOverride) { 106 | return getDescriptorOverrideWeight(b) - getDescriptorOverrideWeight(a); 107 | } 108 | 109 | function getDescriptorOverrideWeight(v: DescriptorOverride): number { 110 | return v.xpath.length + +(v.predicate !== null); 111 | } 112 | 113 | export function getDescriptorOverride( 114 | list: T[], 115 | ctx: EnvContextEntry, 116 | ): T | null { 117 | let override: T | null = null; 118 | 119 | for (let i = 0, n = list.length; i < n; i++) { 120 | override = list[i]; 121 | 122 | const { 123 | xpath, 124 | predicate, 125 | } = override; 126 | const scope = getActiveEnvScope(); 127 | let cursor = scope; 128 | 129 | XPATH: for (let x = 0, xn = xpath.length; x < xn; x++) { 130 | const descr = xpath[x]; 131 | 132 | while (cursor) { 133 | cursor = cursor.parent; 134 | 135 | if (cursor === null || cursor.ctx === ctx) { 136 | override = null; 137 | break XPATH; 138 | } 139 | 140 | if (cursor.owner === descr) { 141 | break; 142 | } 143 | } 144 | } 145 | 146 | if (override !== null && (predicate === null || ( 147 | scope !== null 148 | && scope.ctx !== null 149 | && scope.ctx.props !== null 150 | && predicate(scope.ctx.props) 151 | ))) { 152 | return override; 153 | } 154 | } 155 | 156 | return override; 157 | } -------------------------------------------------------------------------------- /core.types.ts: -------------------------------------------------------------------------------- 1 | export type Omit = Pick> 2 | export type PartialBy = Omit & Partial> 3 | 4 | export type ToIntersect = 5 | (U extends any ? (inp: U) => void : never) extends ((out: infer I) => void) 6 | ? I 7 | : never 8 | ; 9 | 10 | export type CastIntersect = Cast, Y>; 11 | 12 | export type ArrayInfer = T extends (infer U)[] ? U : never; 13 | export type FunctionInfer = F extends (...args: infer A) => infer R ? [A, R] : never; 14 | export type FirstArgInfer = F extends (first: infer F) => any ? F : never; 15 | 16 | export type Optional = T | undefined; 17 | export type IsOptional = ToIntersect extends undefined ? true : false; 18 | export type NonOptional = IsOptional extends true ? NonNullable : T; 19 | 20 | export type FlattenObject = T extends object ? {[K in keyof T]: T[K]} : never; 21 | export type OptionalObject = T extends object ? ToIntersect<{ 22 | [K in keyof T]: IsOptional extends true 23 | ? {[X in K]?: T[K]} 24 | : {[X in K]: T[K]} 25 | }[keyof T]> : never; 26 | 27 | export type CleanObject = CastIntersect<{ 28 | [K in keyof T]: T[K] extends never ? never : {[X in K]: T[K]} 29 | }[keyof T], object>; 30 | 31 | export type Head = T extends [any, ...any[]] 32 | ? T[0] 33 | : never 34 | ; 35 | 36 | export type Tail = 37 | ((...args: T) => any) extends ((_: any, ...tail: infer TT) => any) 38 | ? TT 39 | : [] 40 | ; 41 | 42 | export type HasTail = T extends ([] | [any]) ? false : true; 43 | 44 | export type Last = { 45 | 0: Last> 46 | 1: Head 47 | }[HasTail extends true ? 0 : 1]; 48 | 49 | export type Length = T['length']; 50 | 51 | export type Prepend = 52 | ((head: E, ...args: T) => any) extends ((...args: infer U) => any) 53 | ? U 54 | : T 55 | ; 56 | 57 | export type Cast = X extends Y ? X : Y; 58 | 59 | export interface Descriptor { 60 | readonly id: ID; 61 | readonly name: ID; 62 | readonly isOptional: boolean; 63 | optional(): this | undefined; 64 | } 65 | 66 | export type DescriptorWithMeta = Descriptor & {meta: M}; 67 | 68 | export type DescriptorWith = Descriptor & { 69 | withMeta: () => DescriptorWithMeta; 70 | } 71 | 72 | export type DescriptorWithMetaMap = { 73 | [key:string]: DescriptorWithMeta | undefined; 74 | } 75 | 76 | export const __meta__ = Symbol('__meta__'); 77 | 78 | export type Meta = { 79 | [__meta__]?: T; 80 | } 81 | 82 | export type GetMeta = { 83 | [K in keyof T]-?: K extends symbol ? T[K] : never; 84 | }[keyof T] 85 | 86 | export type Predicate = ((props: T) => boolean) | Partial | null | undefined 87 | export type PredicateFunc = ((props: T) => boolean) | null 88 | export type DescriptorOverride = { 89 | xpath: DescriptorWithMeta[]; 90 | predicate: PredicateFunc; 91 | } 92 | 93 | export type DescriptorOverrideIndex = Map< 94 | DescriptorWithMeta, 95 | T[] 96 | > 97 | 98 | export type LikeFragment = JSX.Element; 99 | export type LikeComponent

= (props: P) => LikeFragment; 100 | 101 | export type IntersectionOf = 102 | (U extends any ? (_: U) => void : never) extends ((_: infer I) => void) ? I : never 103 | ; 104 | 105 | export type PopUnion = ( 106 | IntersectionOf void : never> extends ((_: infer E) => void) ? E : never 107 | ); 108 | 109 | export type IsUnion = [T] extends [IntersectionOf] ? false : true; 110 | 111 | export type ArrayPush = 112 | ((_: any, ...inp: INP) => void) extends ((...out: infer OUT) => void) 113 | ? { [K in keyof OUT]-?: K extends keyof INP ? INP[K] : ELM } 114 | : never 115 | ; 116 | 117 | export type TupleOf< 118 | U extends string, 119 | F = PopUnion, 120 | > = F extends U 121 | ? IsUnion extends true 122 | ? { [K in U]: TupleOfNext, K>; }[F] 123 | : [U] 124 | : [] 125 | ; 126 | 127 | type TupleOfNext = { 128 | next: ArrayPush, K>; 129 | end: [U, K]; 130 | }[IsUnion extends true ? 'next' : 'end']; 131 | -------------------------------------------------------------------------------- /deps/README.md: -------------------------------------------------------------------------------- 1 | @truekit/core: deps 2 | ------------------- 3 | Describing and resolving dependencies of component (aka DI). 4 | 5 | ```sh 6 | npm i --save-dev @truekit/core 7 | ``` 8 | 9 | --- 10 | 11 | ### Usage 12 | 13 | ```tsx 14 | import { Deps } from '@truekit/core/deps'; 15 | import { $Input } from '@truekit/Input/types'; 16 | import { $Button } from '@truekit/Button/types'; 17 | 18 | export type FormProps = { 19 | action: string; 20 | method: 'GET' | 'POST'; 21 | 22 | deps?: Deps<{ 23 | Input: typeof $Input; 24 | Button: typeof $Button; 25 | }>; 26 | } 27 | ``` 28 | 29 | 30 | --- 31 | 32 | 33 | #### API 34 | 35 | - `createDeps` — произволные зависимости 36 | - `createDepsBy` — произволные зависимости описанные по компоненты 37 | - `createStrictDepsBy` — строгие зависимости 38 | - `createDepsOverride` — -------------------------------------------------------------------------------- /deps/__snapshots__/deps.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`deps: btn + form 1`] = ` 4 | " 5 |

6 | 7 | 8 | 9 | 10 | " 11 | `; 12 | 13 | exports[`deps: context 1`] = ` 14 | " 15 | 16 | " 17 | `; 18 | 19 | exports[`deps: defaults 1`] = `""`; 20 | 21 | exports[`deps: fail 1`] = ` 22 | "
23 | 24 | Dep '@truekit/core/deps: input' (aka 'Input') not found 25 | 26 | 27 | 28 | Dep '@truekit/core/deps: button' (aka 'Button') not found 29 | 30 |
31 | " 32 | `; 33 | 34 | exports[`deps: form 1`] = ` 35 | " 36 |
37 | 38 | 39 | 40 |
41 | " 42 | `; 43 | 44 | exports[`deps: inline 1`] = `""`; 45 | -------------------------------------------------------------------------------- /deps/deps.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { 4 | Deps, DepsInline, 5 | } from './deps.types'; 6 | import { 7 | createDepsDescriptor, 8 | createDepsRegistry, 9 | DepsProvider, 10 | createDepsBy, 11 | createStrictDepsBy, 12 | } from './deps'; 13 | import { createDescriptor } from '../core'; 14 | import { withEnvScope } from '../env/env'; 15 | 16 | const $icon = createDescriptor('@truekit/core/deps: icon').withMeta(); 17 | const $button = createDescriptor('@truekit/core/deps: button').withMeta(); 18 | const $input = createDescriptor('@truekit/core/deps: input').withMeta(); 19 | const $form = createDescriptor('@truekit/core/deps: form').withMeta(); 20 | 21 | const $buttonDeps = createDepsDescriptor($button, { 22 | Icon: $icon.optional(), 23 | }); 24 | 25 | const $formDeps = createDepsDescriptor($form, { 26 | Input: $input, 27 | Button: $button, 28 | Icon: $icon.optional(), 29 | }); 30 | 31 | type IconProps = { 32 | name: string; 33 | } 34 | 35 | type ButtonProps = { 36 | iconName: string; 37 | deps?: Deps; 38 | } 39 | 40 | type InputProps = { 41 | type: 'text' | 'password'; 42 | } 43 | 44 | type FormProps = { 45 | action: string; 46 | deps?: Deps; 47 | } 48 | 49 | type GetD = T extends {deps?: infer D} 50 | ? D extends Deps ? DD['map'] : never 51 | : never; 52 | type Foo1 = GetD; 53 | 54 | function BaseIcon(props: IconProps) { 55 | return withEnvScope({}, null, () => ); 56 | } 57 | 58 | function EmIcon(props: IconProps) { 59 | return withEnvScope({}, null, () => ); 60 | } 61 | 62 | function StrongIcon(props: IconProps) { 63 | return withEnvScope({}, null, () => ); 64 | } 65 | 66 | function IconButton(props: ButtonProps) { 67 | return withEnvScope({}, null, () => { 68 | const {Icon = BaseIcon} = $buttonDeps.use(props, null); 69 | return ; 70 | }); 71 | } 72 | 73 | function Input(props: InputProps) { 74 | return withEnvScope({}, null, () => ); 75 | } 76 | 77 | function Form(props: FormProps) { 78 | return withEnvScope({}, null, () => { 79 | const deps = $formDeps.use(props, null); 80 | const {Icon = BaseIcon, Button, Input} = deps; 81 | 82 | return <> 83 |
{'\n'} 84 | {'\t'}{'\n'} 85 | {'\t'}{'\n'} 86 | {'\t'}"`; 27 | 28 | exports[`react inline theme 1`] = `"
Wow!
"`; 29 | 30 | exports[`react with context 1`] = `"
Wow!
,"`; 31 | 32 | exports[`react without (context & inline) 1`] = `"
Wow!
"`; 33 | -------------------------------------------------------------------------------- /theme/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | useTheme, 3 | ThemeProvider, 4 | createThemeFor, 5 | createThemeRegistry, 6 | } from './theme'; 7 | 8 | export { 9 | Theme, 10 | ThemeRegistry, 11 | } from './theme.types'; -------------------------------------------------------------------------------- /theme/theme.convert.ts: -------------------------------------------------------------------------------- 1 | import { ThemeRules, ThemeRule } from './theme.types'; 2 | import { IRuleDefinitions, CSSMap, css } from '@artifact-project/css'; 3 | 4 | export function createToStringCode(code: string[], name: string, classes: object) { 5 | if (typeof classes === 'string') { 6 | code.push(` 7 | if (n === "${name}") { 8 | return (c || ''); 9 | } 10 | `); 11 | return; 12 | } 13 | 14 | const str = Object.entries(classes).map(([key, val]) => { 15 | if (key === '$root') { 16 | return name === 'host' ? `(c.$root || '')` : ''; 17 | } else if (typeof val === 'object') { 18 | return `(k = '${key}', s[k] ? (c[k].$root ? ' ' + c[k].$root : '') + ' ' + (c[k][s[k]] || '') : '')`; 19 | } else { 20 | return `(k = '${key}', s[k] ? ' ' + (c[k] || '') : '')`; 21 | } 22 | }).filter(x => !!x).join(' + '); 23 | 24 | code.push(` 25 | if (n === "${name}") { 26 | return (${str ? str : '""'}); 27 | } 28 | `); 29 | } 30 | 31 | const SEP = '--'; 32 | const COLON_CODE = ':'.charCodeAt(0); 33 | 34 | type SelectorToken = { 35 | type: '+' | '.' | 'fn' | ':'; 36 | raw: string; 37 | } 38 | 39 | class Selector { 40 | constructor(raw: SelectorToken['raw'], type: SelectorToken['type'] = '.', private tokens: SelectorToken[] = []) { 41 | this.tokens.push({raw, type}); 42 | } 43 | 44 | add(raw: SelectorToken['raw'], type: SelectorToken['type'] = '.') { 45 | return new Selector(raw, type, this.tokens.slice(0)); 46 | } 47 | 48 | toString() { 49 | let fnOpened = false; 50 | let prevType: SelectorToken['type']; 51 | 52 | return this.tokens.reduce((s, {raw, type}, idx) => { 53 | if (type === '.') { 54 | if (raw === '&') { 55 | s += '.' + this.tokens[0].raw; 56 | } else if (prevType !== '.' && idx > 0) { 57 | s += '.' + this.tokens[0].raw + SEP + raw; 58 | } else { 59 | s += (prevType === '.' ? SEP : '.') + raw; 60 | } 61 | } else if (type === ':') { 62 | s += raw; 63 | } else { 64 | if (fnOpened) { 65 | s += ')'; 66 | fnOpened = false; 67 | } 68 | 69 | if (type === '+') { 70 | s += ' + '; 71 | } else if (type === 'fn') { 72 | s += raw + '(' 73 | fnOpened = true; 74 | } 75 | } 76 | 77 | if (fnOpened && (this.tokens.length - idx === 1)) { 78 | s += ')'; 79 | fnOpened = false; 80 | } 81 | 82 | prevType = type; 83 | return s; 84 | }, ''); 85 | } 86 | } 87 | 88 | export function convertThemeRulesToCSSRules(rules: ThemeRules, css = {}) { 89 | Object.entries(rules).forEach(([name, rule]) => { 90 | convertThemeRuleToCSSRules(new Selector(name), rule, css); 91 | }); 92 | 93 | return css as IRuleDefinitions; 94 | } 95 | 96 | function convertThemeRuleToCSSRules(sel: Selector, rule: ThemeRule, css: object) { 97 | Object.entries(rule).forEach(([key, value]) => { 98 | if (value && typeof value === 'object') { 99 | if (key.charCodeAt(0) === COLON_CODE) { 100 | if (key === ':modifiers' || key === ':self') { 101 | convertThemeRuleToCSSRules(sel, value!, css); 102 | } else if (key === ':not') { 103 | convertThemeRuleToCSSRules(sel.add(key, 'fn'), value!, css); 104 | } else { 105 | convertThemeRuleToCSSRules(sel.add(key, ':'), value!, css); 106 | } 107 | } else if (key === '+') { 108 | convertThemeRuleToCSSRules(sel.add('+', '+'), value!, css); 109 | } else if (key === '&') { 110 | convertThemeRuleToCSSRules(sel.add('&'), value!, css); 111 | } else { 112 | convertThemeRuleToCSSRules(sel.add(key), value!, css); 113 | } 114 | } else { 115 | const name = sel.toString(); 116 | css[name] = css[name] || {}; 117 | css[name][key] = value; 118 | } 119 | }); 120 | } 121 | 122 | export function convertCSSRulesToCSSClasses(rules: T): CSSMap { 123 | return css(rules) 124 | } 125 | 126 | export function convertCSSClassesToThemeClasses< 127 | T extends IRuleDefinitions, 128 | M extends CSSMap, 129 | >(map: M): object { 130 | const classes = {}; 131 | 132 | Object.entries(map).forEach(([key, val]) => { 133 | const [elem, mod, state] = key.split(SEP); 134 | 135 | if (classes[elem] == null) { 136 | classes[elem] = map[elem] ? {$root: map[elem]} : {}; 137 | } 138 | 139 | if (mod != null && !state && classes[elem][mod] == null) { 140 | classes[elem][mod] = val; 141 | } 142 | 143 | if (state != null) { 144 | if (!classes[elem][mod] || typeof classes[elem][mod] !== 'object') { 145 | classes[elem][mod] = classes[elem][mod] ? {$root: classes[elem][mod]} : {}; 146 | } 147 | 148 | classes[elem][mod][state] = val; 149 | } 150 | }); 151 | 152 | !classes.hasOwnProperty('host') && (classes['host'] = {}); 153 | !classes.hasOwnProperty('elements') && (classes['elements'] = {}); 154 | 155 | return classes; 156 | } 157 | -------------------------------------------------------------------------------- /theme/theme.styler.ts: -------------------------------------------------------------------------------- 1 | export class ThemeStyler { 2 | state = {}; 3 | 4 | constructor(public name: string, public classes: object, private classNames: Function) { 5 | // console.log(name, classes, classNames.toString()); 6 | } 7 | 8 | set(name: string, state: string | boolean) { 9 | this.state[name] = state; 10 | return this; 11 | } 12 | 13 | toDOMProps(props: React.AllHTMLAttributes = {}): React.AllHTMLAttributes { 14 | props.className = (this as any) as string; 15 | return props; 16 | } 17 | 18 | toString() { 19 | return this.classNames(); 20 | } 21 | } 22 | 23 | ThemeStyler.prototype.constructor = String; -------------------------------------------------------------------------------- /theme/theme.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { 4 | createThemeFor, 5 | useTheme, 6 | createThemeRegistry, 7 | ThemeProvider, 8 | createThemeOverrideFor, 9 | createThemeRegistryFor, 10 | } from './theme'; 11 | import { resetCSS } from '@artifact-project/css'; 12 | import { Theme } from './theme.types'; 13 | import { withEnvScope } from '../env/env'; 14 | import { createDescriptor } from '../core'; 15 | import { createDepsDescriptor, Deps } from '../deps'; 16 | // import * as classnames from 'classnames'; 17 | // import { now } from '@perf-tools/performance'; 18 | 19 | const $text = createDescriptor('@truekit/core/theme: Text').withMeta(); 20 | const $icon = createDescriptor('@truekit/core/theme: Icon').withMeta(); 21 | const $btn = createDescriptor('@truekit/core/theme: Btn').withMeta(); 22 | 23 | const $btnDeps = createDepsDescriptor($btn, { 24 | [$text.id]: $text, 25 | [$icon.id]: $icon.optional(), 26 | }); 27 | 28 | type TextProps = { 29 | children?: React.ReactNode; 30 | size?: 'big' | 'small'; 31 | block?: boolean; 32 | type?: 'default' | 'error'; 33 | disabled?: boolean; 34 | theme?: Theme<{ 35 | host: Pick & { 36 | multiline: 'on' | 'off'; 37 | }; 38 | elements: { 39 | value: boolean; 40 | }; 41 | }>; 42 | } 43 | 44 | type IconProps = { 45 | size?: 'big' | 'small'; 46 | theme?: Theme<{ 47 | host: Pick; 48 | elements: {}; 49 | }>; 50 | } 51 | 52 | type BtnProps = { 53 | value?: string; 54 | deps?: Deps; 55 | theme?: Theme<{ 56 | host: {}; 57 | elements: {}; 58 | }>; 59 | } 60 | 61 | function Text(props: TextProps) { 62 | return withEnvScope($text, null, () => { 63 | const hostTheme = useTheme($text, props, null).for('host'); 64 | const result =
{props.children}
; 65 | 66 | props.size && hostTheme.set('size', props.size); 67 | props.disabled && hostTheme.set('disabled', true); 68 | 69 | return result; 70 | }); 71 | } 72 | 73 | function Icon(props: IconProps) { 74 | return withEnvScope($icon, {props, deps: null, depsInjection: null, theme: null}, () => { 75 | const hostTheme = useTheme($icon, props, null).for('host'); 76 | const result = ; 77 | 78 | props.size && hostTheme.set('size', props.size); 79 | 80 | return result; 81 | }); 82 | } 83 | 84 | function Btn(props: BtnProps) { 85 | return withEnvScope($btn, null, () => { 86 | const hostTheme = useTheme($btn, props, null).for('host'); 87 | return ; 88 | }); 89 | } 90 | 91 | beforeEach(() => { 92 | process.env.NODE_ENV = 'production'; 93 | resetCSS(0); 94 | }); 95 | 96 | describe('createThemeFor', () => { 97 | it('host', () => { 98 | const theme = createThemeFor($text, { 99 | host: {color: '#333'}, 100 | elements: {}, 101 | }); 102 | 103 | expect(theme.descriptor).toBe($text); 104 | expect(theme.cssRules).toEqual({ 105 | '.host': {color: '#333'}, 106 | }); 107 | expect(theme.classes).toEqual({ 108 | host: {$root: '_0'}, 109 | elements: {}, 110 | }); 111 | expect(theme.for('host').toString()).toBe('_0'); 112 | }); 113 | 114 | it('for equal for (but not persist)', () => { 115 | const theme = createThemeFor($text, { 116 | host: {color: '#333'}, 117 | elements: {}, 118 | }); 119 | const host1 = theme.for('host'); 120 | const host2 = theme.for('host'); 121 | 122 | expect(host1).not.toBe(host2); 123 | expect(host1 + '').toBe(host2 + ''); 124 | }); 125 | 126 | // Создаёт тему у которой метод `for` будет вовращать костантное значение 127 | it('persist', () => { 128 | const theme = createThemeFor($text, { 129 | host: {color: '#333'}, 130 | elements: {}, 131 | }).persist({}); 132 | const host1 = theme.for('host'); 133 | const host2 = theme.for('host'); 134 | 135 | expect(host1).toBe(host2); 136 | expect(host1 + '').toBe(host2 + ''); 137 | }); 138 | 139 | it('host - element', () => { 140 | const theme = createThemeFor($text, { 141 | host: {color: '#333'}, 142 | elements: {value: {color: 'red'}}, 143 | }); 144 | 145 | expect(theme.cssRules).toEqual({ 146 | '.host': {color: '#333'}, 147 | '.elements--value': {color: 'red'}, 148 | }); 149 | expect(theme.classes).toEqual({ 150 | host: {$root: '_0'}, 151 | elements: {value: '_1'}, 152 | }); 153 | expect(theme.for('value').toString()).toBe('_1'); 154 | }); 155 | 156 | it('host - modifiers', () => { 157 | const theme = createThemeFor($text, { 158 | host: { 159 | color: '#333', 160 | ':modifiers': { 161 | size: { 162 | ':self': {fontSize: '100%'}, 163 | big: {fontSize: '200%'}, 164 | small: {fontSize: '50%'}, 165 | }, 166 | disabled: { 167 | opacity: .5, 168 | }, 169 | }, 170 | }, 171 | elements: {}, 172 | }); 173 | 174 | expect(theme.cssRules).toEqual({ 175 | '.host': {color: '#333'}, 176 | '.host--size': {fontSize: '100%'}, 177 | '.host--size--big': {fontSize: '200%'}, 178 | '.host--size--small': {fontSize: '50%'}, 179 | '.host--disabled': {opacity: .5}, 180 | }); 181 | 182 | expect(theme.classes).toEqual({ 183 | host: { 184 | $root: '_0', 185 | size: {$root: '_1', big: '_2', small: '_3'}, 186 | disabled: '_4', 187 | }, 188 | elements: {}, 189 | }); 190 | expect(theme.for('host').toString()).toBe('_0'); 191 | expect(theme.for('host').set('size', 'small').toString()).toBe('_0 _1 _3'); 192 | expect(theme.for('host').set('size', 'big').toString()).toBe('_0 _1 _2'); 193 | expect(theme.for('host').set('disabled', true).toString()).toBe('_0 _4'); 194 | expect(theme.for('host').set('size', 'small').set('disabled', true).toString()).toBe('_0 _1 _3 _4'); 195 | }); 196 | 197 | // it('host + &', () => { 198 | // const theme = createThemeFor($text, { 199 | // host: { 200 | // color: '#333', 201 | // '+': { 202 | // '&': { 203 | // marginLeft: 5, 204 | // '+': { 205 | // ':not': { 206 | // '&': { 207 | // marginLeft: 10, 208 | // '+': {'&': {marginLeft: 15}}, 209 | // ':not': { 210 | // ':first-child': { 211 | // marginLeft: 0, 212 | // }, 213 | // }, 214 | // }, 215 | // }, 216 | // }, 217 | // }, 218 | // }, 219 | // }, 220 | // elements: {}, 221 | // }); 222 | 223 | // expect(theme.cssRules).toEqual({ 224 | // '.host': {color: '#333'}, 225 | // '.host + .host': {marginLeft: 5}, 226 | // '.host + .host + :not(.host)': {marginLeft: 10}, 227 | // '.host + .host + :not(.host) + .host': {marginLeft: 15}, 228 | // '.host + .host + :not(.host):not(:first-child)': {marginLeft: 0}, 229 | // }); 230 | 231 | // expect(theme.classes).toEqual({ 232 | // host: {$root: '_0'}, 233 | // elements: {}, 234 | // }); 235 | // }); 236 | 237 | it('host - modifiers - not', () => { 238 | const theme = createThemeFor($text, { 239 | host: { 240 | color: '#333', 241 | ':modifiers': { 242 | block: { 243 | width: '100%', 244 | ':not': { 245 | multiline: {on: {wordBreak: 'normal', whiteSpace: 'nowrap'}}, 246 | }, 247 | }, 248 | multiline: {off: {wordBreak: 'normal', whiteSpace: 'nowrap'}}, 249 | type: { 250 | ':self': {fontWeight: 'normal'}, 251 | default: {fontSize: '100%'}, 252 | error: {color: 'red'}, 253 | }, 254 | }, 255 | }, 256 | elements: {}, 257 | }); 258 | 259 | expect(theme.cssRules).toEqual({ 260 | '.host': {color: '#333'}, 261 | '.host--block': {width: '100%'}, 262 | '.host--block:not(.host--multiline--on)': {wordBreak: 'normal', whiteSpace: 'nowrap'}, 263 | '.host--multiline--off': {wordBreak: 'normal', whiteSpace: 'nowrap'}, 264 | '.host--type': {fontWeight: 'normal'}, 265 | '.host--type--default': {fontSize: '100%'}, 266 | '.host--type--error': {color: 'red'}, 267 | }); 268 | 269 | expect(theme.classes).toEqual({ 270 | host: { 271 | $root: '_0', 272 | block: '_1', 273 | multiline: { 274 | on: '_2', 275 | off: '_3', 276 | }, 277 | type: { 278 | $root: '_4', 279 | default: '_5', 280 | error: '_6', 281 | }, 282 | }, 283 | elements: {}, 284 | }); 285 | }); 286 | }); 287 | 288 | const root = document.createElement('div'); 289 | 290 | function render(fragment: JSX.Element) { 291 | ReactDOM.render(fragment, root,); 292 | return root.innerHTML; 293 | } 294 | 295 | describe('react', () => { 296 | it('inline theme', () => { 297 | const theme = createThemeFor($text, { 298 | host: { 299 | color: 'red', 300 | ':modifiers': { 301 | size: {small: {fontSize: '50%'}}, 302 | }, 303 | }, 304 | elements: {}, 305 | }); 306 | 307 | expect(render( 308 | Wow!, 309 | )).toMatchSnapshot(); 310 | }); 311 | 312 | it('without (context & inline)', () => { 313 | expect(render( 314 | Wow!, 315 | )).toMatchSnapshot(); 316 | }); 317 | 318 | it('with context', () => { 319 | const rootTheme = createThemeRegistry([ 320 | createThemeFor($icon, { 321 | host: { 322 | display: 'inline-block', 323 | ':modifiers': { 324 | size: {big: {fontSize: 300}}, 325 | }, 326 | }, 327 | elements: {}, 328 | }), 329 | ]); 330 | 331 | const theme = createThemeRegistry([ 332 | createThemeFor($text, { 333 | host: { 334 | color: '#333', 335 | ':modifiers': { 336 | disabled: {color: 'red'}, 337 | }, 338 | }, 339 | elements: {}, 340 | }), 341 | ]); 342 | 343 | expect(render( 344 | 345 | 346 | Wow! 347 | 348 | , 349 | , 350 | )).toMatchSnapshot(); 351 | }); 352 | 353 | it('createThemeRegistryFor', () => { 354 | const iconTheme = createThemeFor($icon, { 355 | host: {color: 'red'}, 356 | elements: {}, 357 | }); 358 | const btnTheme = createThemeFor($btn, { 359 | host: {color: 'green'}, 360 | elements: {}, 361 | }); 362 | const textTheme = createThemeFor($text, { 363 | host: {color: 'blue'}, 364 | elements: {}, 365 | }); 366 | const rootTheme = createThemeRegistryFor($btn, { 367 | [$btn.id]: btnTheme, 368 | [$text.id]: textTheme, 369 | [$icon.id]: iconTheme, 370 | }); 371 | 372 | expect(render( 373 | 374 | 375 | 376 | )).toMatchSnapshot(); 377 | }); 378 | }); 379 | 380 | describe('overrides', () => { 381 | it('createThemeOverrideFor', () => { 382 | const textTheme = createThemeFor($text, { 383 | host: {color: '#333'}, 384 | elements: {}, 385 | }); 386 | const iconTheme = createThemeFor($icon, { 387 | host: {color: 'black'}, 388 | elements: {}, 389 | }); 390 | const redIconTheme = createThemeFor($icon, { 391 | host: {color: 'red'}, 392 | elements: {}, 393 | }); 394 | const blueIconTheme = createThemeFor($icon, { 395 | host: {color: 'blue'}, 396 | elements: {}, 397 | }); 398 | const purpleIconTheme = createThemeFor($icon, { 399 | host: {color: 'purple'}, 400 | elements: {}, 401 | }); 402 | const rootTheme = createThemeRegistry([textTheme, iconTheme]); 403 | const redTheme = createThemeRegistry(null, [ 404 | createThemeOverrideFor($icon, [$text, $text])(purpleIconTheme), 405 | createThemeOverrideFor($icon, [$text])(redIconTheme), 406 | ]); 407 | const blueTheme = createThemeRegistry(null, [ 408 | createThemeOverrideFor($icon, [$text])(blueIconTheme), 409 | ]); 410 | const icon = ; 411 | 412 | expect(render( 413 | 414 | 415 | {'\n'} 416 | {'\t'}red: {'\n'} 417 | {'\n'} 418 | 419 | {'\n'} 420 | 421 | {'\t'}red: {icon}{'\n'} 422 | {'\t'}{'\n'} 423 | {'\t\t'}blue: {icon}{'\n'} 424 | {'\t'}{'\n'} 425 | 426 | {'\t'}{'\n'} 427 | {'\t\t'}purple: {icon}{'\n'} 428 | {'\t'}{'\n'} 429 | {'\n'} 430 | 431 | black: {'\n'} 432 | 433 | 434 | black: 435 | , 436 | )).toMatchSnapshot(); 437 | }); 438 | 439 | it('createThemeOverrideFor with predicate', () => { 440 | const redIconTheme = createThemeFor($icon, { 441 | host: {color: 'red'}, 442 | elements: {}, 443 | }); 444 | const purpleIconTheme = createThemeFor($icon, { 445 | host: {color: 'purple'}, 446 | elements: {}, 447 | }); 448 | const overrideTheme = createThemeRegistry(null, [ 449 | createThemeOverrideFor($icon, [], {size: 'big'})(purpleIconTheme), 450 | createThemeOverrideFor($icon, [])(redIconTheme), 451 | ]); 452 | 453 | expect(render( 454 | 455 | {'\t'}red: {'\n'} 456 | {'\t'}purpleIconTheme: {'\n'} 457 | 458 | )).toMatchSnapshot(); 459 | }); 460 | }); 461 | 462 | // it('performance', () => { 463 | // type TestProps = { 464 | // theme?: Theme<{ 465 | // primary: boolean; 466 | // warning: boolean; 467 | // danger: boolean; 468 | // fluid: boolean; 469 | // large: boolean; 470 | // flat: boolean; 471 | // disabled: boolean; 472 | // mobile: boolean; 473 | // auto: boolean; 474 | // icon: boolean; 475 | // }>; 476 | // }; 477 | // const cssRule = {color: 'inherit'}; 478 | // const TestCmp = (_: TestProps) =>
; 479 | // const theme = createThemeFor(TestCmp)({ 480 | // ':host': cssRule, 481 | // primary: cssRule, 482 | // warning: cssRule, 483 | // danger: cssRule, 484 | // fluid: cssRule, 485 | // large: cssRule, 486 | // flat: cssRule, 487 | // disabled: cssRule, 488 | // mobile: cssRule, 489 | // auto: cssRule, 490 | // icon: cssRule, 491 | // }); 492 | // const maxIter = 1e3; 493 | // const classes = { 494 | // base: theme.classes[':host'], 495 | // ...theme.classes, 496 | // }; 497 | // const states = Array.from({length: maxIter}).map(() => ({ 498 | // primary: Math.random() > .5, 499 | // warning: Math.random() > .5, 500 | // danger: Math.random() > .5, 501 | // fluid: Math.random() > .5, 502 | // large: Math.random() > .5, 503 | // flat: Math.random() > .5, 504 | // disabled: Math.random() > .5, 505 | // mobile: Math.random() > .5, 506 | // auto: Math.random() > .5, 507 | // icon: Math.random() > .5, 508 | // })); 509 | // const pkgCSSTheme = []; 510 | // const pkgClassNames = []; 511 | 512 | // // pkg: classnames 513 | // let pkgClassNamesTime = -now(); 514 | // for (let i = 0; i < maxIter; i++) { 515 | // const s = states[i]; 516 | // const c = classes; 517 | // pkgClassNames.push(classnames( 518 | // c.base, 519 | // { 520 | // [c.primary]: s.primary, 521 | // [c.warning]: s.warning, 522 | // [c.danger]: s.danger, 523 | // [c.fluid]: s.fluid, 524 | // [c.large]: s.large, 525 | // [c.flat]: s.flat, 526 | // [c.disabled]: s.disabled, 527 | // [c.mobile]: s.mobile, 528 | // [c.auto]: s.auto, 529 | // [c.icon]: s.icon, 530 | // }, 531 | // )); 532 | // } 533 | // pkgClassNamesTime += now(); 534 | 535 | // // pkg: css/theme 536 | // let pkgCSSThemeTime = -now(); 537 | // for (let i = 0; i < maxIter; i++) { 538 | // const s = states[i]; 539 | // const style = theme.create(); 540 | 541 | // style.set('primary', s.primary); 542 | // style.set('warning', s.warning); 543 | // style.set('danger', s.danger); 544 | // style.set('fluid', s.fluid); 545 | // style.set('large', s.large); 546 | // style.set('flat', s.flat); 547 | // style.set('disabled', s.disabled); 548 | // style.set('mobile', s.mobile); 549 | // style.set('auto', s.auto); 550 | // style.set('icon', s.icon); 551 | 552 | // pkgCSSTheme.push(style.toString()); 553 | // } 554 | // pkgCSSThemeTime += now(); 555 | 556 | // expect(pkgCSSTheme).toEqual(pkgClassNames); 557 | // expect(pkgCSSThemeTime).toBeLessThan(pkgClassNamesTime); 558 | // }); -------------------------------------------------------------------------------- /theme/theme.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ThemeRules, 3 | ThemeSpec, 4 | Theme, 5 | ThemeRegistry, 6 | ThemeOverride, 7 | AllThemes, 8 | GetThemeFromDescriptor, 9 | } from './theme.types'; 10 | 11 | import { 12 | convertThemeRulesToCSSRules, 13 | convertCSSRulesToCSSClasses, 14 | convertCSSClassesToThemeClasses, 15 | createToStringCode, 16 | } from './theme.convert'; 17 | 18 | import { ThemeStyler } from './theme.styler'; 19 | 20 | import { DescriptorWithMeta, Predicate } from '../core.types'; 21 | import { EnvContextEntry } from '../env/env.types'; 22 | import { createEnvContextProvider, getEnvContext, getActiveEnvScope } from '../env/env'; 23 | import { createDescriptorOverride, createDescriptorOverrideIndex } from '../core'; 24 | 25 | 26 | export function createThemeRegistryFor< 27 | D extends DescriptorWithMeta 28 | >( 29 | _: D, 30 | themesMap: AllThemes, 31 | overrides?: ThemeOverride[] | null, 32 | ): ThemeRegistry { 33 | return createThemeRegistry( 34 | Object.values(themesMap).map(theme => theme) as Theme[], 35 | overrides, 36 | ); 37 | } 38 | 39 | export function createThemeFor< 40 | D extends DescriptorWithMeta}>, 41 | TS extends ThemeSpec = NonNullable['spec'], 42 | >(descriptor: D, rules: ThemeRules): Theme { 43 | const cssRules = convertThemeRulesToCSSRules(rules); 44 | const cssClasses = convertCSSRulesToCSSClasses(cssRules); 45 | const classes = convertCSSClassesToThemeClasses(cssClasses); 46 | const toStringCode = [ 47 | ` 48 | var k; 49 | var n = this.name; 50 | var s = this.state; 51 | var c = this.classes; 52 | ` 53 | ]; 54 | 55 | createToStringCode(toStringCode, 'host', classes['host']); 56 | Object.entries(classes['elements']).forEach(([name, classes]: [string, object]) => { 57 | createToStringCode(toStringCode, name, classes); 58 | }); 59 | 60 | const classNames = Function(` 61 | ${toStringCode.join('\n')} 62 | return ''; 63 | `); 64 | 65 | function _for(name: string) { 66 | const store = this.store; 67 | 68 | if (store === null || store.hasOwnProperty(name)) { 69 | const style = new ThemeStyler( 70 | name, 71 | name === 'host' ? classes[name] : classes['elements'][name], 72 | classNames, 73 | ) as any; 74 | 75 | if (store === null) { 76 | return style 77 | } 78 | 79 | store[name] = style; 80 | } 81 | 82 | return store[name]; 83 | } 84 | 85 | return { 86 | descriptor: descriptor as any, 87 | for: _for, 88 | cssRules, 89 | classes, 90 | store: null, 91 | persist(store: object) { 92 | const wr = Object.create(this); 93 | wr.store = store; 94 | return wr; 95 | }, 96 | } as Theme; 97 | } 98 | 99 | const nullTheme = createThemeFor(null as any, { 100 | host: {}, 101 | elements: {}, 102 | }); 103 | 104 | export const ThemeProvider = createEnvContextProvider('theme'); 105 | 106 | export function useTheme< 107 | D extends DescriptorWithMeta}>, 108 | T extends Theme = NonNullable, 109 | >( 110 | descriptor: D, 111 | props: {theme?: T}, 112 | ctx: EnvContextEntry | null, 113 | ): T { 114 | let theme = props.theme; 115 | 116 | if (theme == null) { 117 | theme = nullTheme as T; 118 | 119 | if (ctx === null) { 120 | ctx = getEnvContext(); 121 | } 122 | 123 | while (ctx && ctx.theme) { 124 | const { 125 | map, 126 | overrides, 127 | } = ctx.theme; 128 | 129 | if ( 130 | overrides !== null 131 | && getActiveEnvScope() !== null 132 | && overrides.has(descriptor) 133 | ) { 134 | const overTheme = getThemeOverride(overrides.get(descriptor)!, ctx); 135 | if (overTheme !== null) { 136 | theme = overTheme as T; 137 | break; 138 | } 139 | } 140 | 141 | if (map !== null && map.has(descriptor)) { 142 | theme = map.get(descriptor) as T; 143 | break; 144 | } 145 | 146 | ctx = ctx.parent!; 147 | } 148 | } 149 | 150 | return theme; 151 | } 152 | 153 | function getThemeOverride(list: ThemeOverride[], ctx: EnvContextEntry): Theme | null { 154 | for (let i = 0; i < list.length; i++) { 155 | let { 156 | value:theme, 157 | xpath, 158 | predicate, 159 | } = list[i]; 160 | const scope = getActiveEnvScope(); 161 | let cursor = scope; 162 | 163 | XPATH: for (let x = 0, xn = xpath.length; x < xn; x++) { 164 | const descr = xpath[x]; 165 | 166 | while (cursor) { 167 | cursor = cursor.parent; 168 | 169 | if (cursor === null || cursor.ctx === ctx) { 170 | theme = null as any; 171 | break XPATH; 172 | } 173 | 174 | if (cursor.owner === descr) { 175 | break; 176 | } 177 | } 178 | } 179 | 180 | if (theme !== null && (predicate === null || ( 181 | scope !== null 182 | && scope.ctx !== null 183 | && scope.ctx.props !== null 184 | && predicate(scope.ctx.props) 185 | ))) { 186 | return theme; 187 | } 188 | } 189 | 190 | return null; 191 | } 192 | 193 | export function createThemeRegistry( 194 | themes: Theme[] | null, 195 | overrides?: ThemeOverride[] | null, 196 | ): ThemeRegistry { 197 | return { 198 | map: !themes ? null : themes.reduce((map, theme) => { 199 | map.set(theme.descriptor, theme); 200 | return map; 201 | }, new Map), 202 | 203 | overrides: !overrides ? null : createDescriptorOverrideIndex(overrides), 204 | }; 205 | } 206 | 207 | export function createThemeOverrideFor< 208 | D extends DescriptorWithMeta}> 209 | >( 210 | Target: D, 211 | specificPath: DescriptorWithMeta[], 212 | predicate?: Predicate, 213 | ): ( 214 | theme: GetThemeFromDescriptor, 215 | ) => ThemeOverride { 216 | return (theme) => ({ 217 | ...createDescriptorOverride(Target, specificPath, predicate), 218 | value: theme, 219 | }); 220 | } -------------------------------------------------------------------------------- /theme/theme.types.ts: -------------------------------------------------------------------------------- 1 | import { IRuleDefinitions } from '@artifact-project/css'; 2 | import { 3 | FlattenObject, 4 | DescriptorWithMeta, 5 | GetMeta, 6 | CastIntersect, 7 | DescriptorOverride, 8 | DescriptorOverrideIndex, 9 | } from '../core.types'; 10 | import { Deps } from '../deps'; 11 | 12 | export type GetThemeFromDescriptor = D extends DescriptorWithMeta ? T : never; 13 | 14 | export type ThemeModsSpec = { 15 | [name:string]: string | boolean | undefined; 16 | } 17 | 18 | export type ThemeElementsSpec = { 19 | [name:string]: boolean | ThemeModsSpec; 20 | } 21 | 22 | export type ThemeSpec = { 23 | host: ThemeModsSpec; 24 | elements: ThemeElementsSpec; 25 | } 26 | 27 | export type Theme = { 28 | readonly spec: { 29 | host: Required; 30 | elements: Required; 31 | }; 32 | 33 | readonly descriptor: DescriptorWithMeta}>; 34 | readonly cssRules: IRuleDefinitions; 35 | readonly store: object | null; 36 | 37 | readonly classes: { 38 | host: ThemeClasses; 39 | elements: ThemeElementsClasses; 40 | }; 41 | 42 | // Get theme from `host` or element 43 | for(name: N): N extends K 44 | ? string & ThemeElement 45 | : string & ThemeElement 46 | ; 47 | 48 | persist(store: object): Theme; 49 | } 50 | 51 | export type ThemeClasses = {$root: string} & { 52 | [K in keyof T]: string; 53 | } 54 | 55 | export type ThemeElementsClasses = { 56 | [K in keyof T]?: T[K] extends ThemeModsSpec ? ThemeClasses : {$root: string}; 57 | } 58 | 59 | export type ThemeElement = { 60 | name: N; 61 | toString(): string; 62 | } & (T extends ThemeModsSpec 63 | ? { 64 | set(name: K, state: T[K]): ThemeElement; 65 | classes: {$root: string} & {[K in keyof T]: string}; 66 | } 67 | : { 68 | set(): ThemeElement; 69 | classes: {$root: string}; 70 | } 71 | ) & { 72 | toDOMProps(props?: React.AllHTMLAttributes): React.AllHTMLAttributes; 73 | }; 74 | 75 | export type CSSProps = Partial; 76 | 77 | type CanSelf = '>' | '+' | '!'; 78 | type ThemeRuleType = '&' | '>' | '+' | '!'; 79 | 80 | // Rule 81 | export type ThemeRule< 82 | M extends ThemeModsSpec | ThemeElementsSpec, 83 | E extends ThemeElementsSpec, 84 | A extends ThemeModsSpec | ThemeElementsSpec = E, // Adjacent Sibling Selector 85 | T extends ThemeRuleType = '&' 86 | > = CSSProps 87 | & ThemePseudoRules 88 | & (T extends '!' ? {} : ThemeNotRule) 89 | & (T extends '+' ? {} : ThemePlusRule) 90 | & (T extends '>' ? {} : ThemeNestedRule) 91 | & (T extends CanSelf ? ThemeSelfRefRule : {}) 92 | 93 | 94 | // '*' — AnyRule 95 | type ThemeAnyRule = { 96 | '*'?: { 97 | [selector:string]: CSSProps; 98 | }; 99 | }; 100 | 101 | // '+' 102 | type ThemePlusRule< 103 | M extends ThemeModsSpec | ThemeElementsSpec, 104 | E extends ThemeElementsSpec, 105 | A extends ThemeModsSpec | ThemeElementsSpec = E, 106 | > = { 107 | '+'?: ThemePseudoRules & ThemeNotRule & ThemeSelfRefRule; 108 | } 109 | 110 | // '*' 111 | type ThemeNestedRule< 112 | M extends ThemeModsSpec | ThemeElementsSpec, 113 | E extends ThemeElementsSpec, 114 | > = { 115 | ':nested'?: ThemeAnyRule & ThemeElementsRules'> & ThemeNotRule 116 | } 117 | 118 | // ':not(...)' 119 | type ThemeNotRule< 120 | M extends ThemeModsSpec | ThemeElementsSpec, 121 | E extends ThemeElementsSpec, 122 | A extends ThemeModsSpec | ThemeElementsSpec = E, 123 | > = { 124 | ':not'?: ThemePseudoRules & { 125 | [K in keyof M]?: M[K] extends string 126 | ? { 127 | [V in M[K]]?: ThemeRule; 128 | } 129 | : (M[K] extends ThemeModsSpec 130 | ? ThemeRule & ThemeModifiersRule 131 | : ThemeRule 132 | ) 133 | }; 134 | } 135 | 136 | // '&' — Self reference rule 137 | export type ThemeSelfRefRule< 138 | M extends ThemeModsSpec | ThemeElementsSpec, 139 | E extends ThemeElementsSpec, 140 | A extends ThemeModsSpec | ThemeElementsSpec = E, 141 | T extends ThemeRuleType = '&', 142 | > = ThemeAnyRule 143 | & (T extends '!' ? {} : ThemeNotRule) 144 | & (A extends ThemeModsSpec 145 | ? { 146 | '&'?: (M extends ThemeElementsSpec 147 | ? ThemeRule & ThemeModifiersRule 148 | : ThemeHostRule 149 | ); 150 | } 151 | 152 | : ThemeElementsRules & { 153 | '&'?: (M extends ThemeModsSpec 154 | ? ThemeRule & ThemeModifiersRule 155 | : ThemeRule 156 | ); 157 | } 158 | ) 159 | 160 | // ':pseudo' — Pseudo selector rule 161 | export type ThemePseudoRules< 162 | M extends ThemeModsSpec | ThemeElementsSpec, 163 | E extends ThemeElementsSpec, 164 | > = { 165 | ':first-child'?: ThemeRule; 166 | ':last-child'?: ThemeRule; 167 | ':before'?: ThemeRule; 168 | ':after'?: ThemeRule; 169 | ':hover'?: ThemeRule; 170 | ':active'?: ThemeRule; 171 | ':visited'?:ThemeRule; 172 | ':focus'?: ThemeRule; 173 | } 174 | 175 | // Rules for 'host' & 'elements' 176 | export type ThemeRules = { 177 | host: ThemeHostRule; 178 | elements: ThemeElementsRules; 179 | } 180 | 181 | // ':host' — Host Rule 182 | export type ThemeHostRule< 183 | M extends ThemeModsSpec, // Modifiers 184 | E extends ThemeElementsSpec, // Elements 185 | > = ThemeRule & ThemeModifiersRuleWithSelf; 186 | 187 | // Modifiers Rule 188 | export type ThemeModifiersRule< 189 | M extends ThemeModsSpec, // Modifiers 190 | E extends ThemeElementsSpec, 191 | > = { 192 | ':modifiers'?: { 193 | [K in keyof M]?: M[K] extends string 194 | ? {[V in M[K]]?: ThemeRule} 195 | : ThemeRule 196 | }; 197 | } 198 | 199 | export type ThemeModifiersRuleWithSelf< 200 | M extends ThemeModsSpec, // Modifiers 201 | E extends ThemeElementsSpec, 202 | > = { 203 | ':modifiers'?: { 204 | [K in keyof M]?: M[K] extends string 205 | ? { 206 | [V in M[K]]?: ThemeRule; 207 | } & { 208 | ':self'?: ThemeRule; 209 | } 210 | : ThemeRule 211 | }; 212 | } 213 | 214 | export type ThemeElementsRules< 215 | E extends ThemeElementsSpec, 216 | T extends ThemeRuleType = '&', 217 | > = { 218 | [K in keyof E]?: E[K] extends ThemeModsSpec 219 | ? ThemeRule & ThemeModifiersRule 220 | : ThemeRule 221 | ; 222 | } 223 | 224 | export type ThemeRegistry = { 225 | map: Map, Theme> | null; 226 | overrides: ThemeOverrideIndex | null; 227 | } 228 | 229 | export type ThemeOverride = DescriptorOverride & { 230 | value: Theme; 231 | } 232 | 233 | export type ThemeOverrideIndex = DescriptorOverrideIndex; 234 | 235 | export type AllThemes< 236 | T extends DescriptorWithMeta 237 | > = FlattenObject< 238 | CastIntersect< 239 | GetAllThemes, 240 | object 241 | > 242 | > 243 | 244 | type GetAllThemes> = ( 245 | (T['meta'] extends {theme?: Theme} 246 | ? { 247 | [X in T['id']]: NonNullable 248 | } 249 | : never 250 | ) 251 | | { // Recursion 252 | next: DepsThemes>>; 253 | exit: never; 254 | }[T['meta'] extends {deps?: Deps} ? 'next' : 'exit'] 255 | ) 256 | 257 | type DepsThemes}> = { 258 | [K in keyof T]-?: GetAllThemes>; 259 | }[keyof T] -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "module": "commonjs", 5 | "target": "es5", 6 | "declaration": true, 7 | "moduleResolution": "node", 8 | "experimentalDecorators": true, 9 | "strictNullChecks": true, 10 | "lib": [ 11 | "dom", 12 | "es5", 13 | "es2015", 14 | "es2016", 15 | "es2017", 16 | "es2018" 17 | ] 18 | }, 19 | "exclude": [ 20 | "dist", 21 | "node_modules" 22 | ] 23 | } 24 | --------------------------------------------------------------------------------