diff --git a/.commitlintrc.js b/.commitlintrc.js index 8932f24bdb..963732bc94 100644 --- a/.commitlintrc.js +++ b/.commitlintrc.js @@ -19,6 +19,7 @@ module.exports = { 'web-react', 'web-twig', // Use when committing changes/additions/removals to exact exporter + 'exporter-js', 'exporter-scss', 'exporter-svg', // Use when affecting CI process diff --git a/.eslintignore b/.eslintignore index fe660d0e28..199bbaaa41 100644 --- a/.eslintignore +++ b/.eslintignore @@ -9,6 +9,10 @@ node_modules # If you compile JavaScript into some output folder, exclude it here dist build +packages/**/types/* +packages/**/esm/* +packages/**/cjs/* +packages/**/umd/* # Highly recommended to re-include JavaScript dotfiles to lint them # (This will cause .eslintrc.js to be linted by ESLint 🤘) diff --git a/.eslintrc.js b/.eslintrc.js index a501d163c2..737da60a73 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,7 +19,8 @@ module.exports = { 'packages/web-react', 'packages/web', 'packages/form-validations', - 'exporters/scss' + 'exporters/scss', + 'exporters/js', ], extends: ['@lmc-eu/eslint-config-react/base', '@lmc-eu/eslint-config-react/optional', 'prettier', 'plugin:prettier/recommended', 'plugin:storybook/recommended'], diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f4a1d8a705..231d375e77 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -32,6 +32,9 @@ jobs: with: useRollingCache: true + - name: Build + run: yarn build + - name: Check code-style format run: yarn format:check diff --git a/.prettierignore b/.prettierignore index b0958ec761..13fdc35072 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,10 @@ # Build files **/dist **/build +packages/**/types/* +packages/**/esm/* +packages/**/cjs/* +packages/**/umd/* # Ignoring html files because Prettier prefers closing slash on the end of the tag # @See: https://github.com/lmc-eu/spirit-design-system/pull/77/files#r764183332 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fae8aee75f..4190315c1b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,8 +52,8 @@ The `footer` is optional. The [Commit Message Footer](#commit-footer) format des │ │ │ │ │ └─⫸ Summary in present tense. Sentence case. No period at the end. │ │ - │ └─⫸ Commit Scope: analytics|design-tokens|form-validations|icons|web|web-react|web-twig|exporter-scss| - │ exporter-svg|ci|repo + │ └─⫸ Commit Scope: analytics|design-tokens|form-validations|icons|web|web-react|web-twig|exporter-js| + | exporter-scss|exporter-svg|ci|repo │ └─⫸ Commit Type: Feat|Fix|Perf|Revert|Docs|Style|Refactor|Test|Chore|Deps ``` @@ -84,6 +84,7 @@ The following is the list of supported scopes: - Apps: - `demo` - Exporters: + - `exporter-js` - `exporter-scss` - `exporter-svg` - Packages: diff --git a/exporters/js/.eslintignore b/exporters/js/.eslintignore new file mode 100644 index 0000000000..01a6927ac6 --- /dev/null +++ b/exporters/js/.eslintignore @@ -0,0 +1,2 @@ +# Generated files used by Supernova +generated diff --git a/exporters/js/.eslintrc.js b/exporters/js/.eslintrc.js new file mode 100644 index 0000000000..eda1c89421 --- /dev/null +++ b/exporters/js/.eslintrc.js @@ -0,0 +1,46 @@ +module.exports = { + extends: [ + '../../.eslintrc', + 'plugin:@typescript-eslint/recommended', + 'prettier', + 'plugin:prettier/recommended', + '@lmc-eu/eslint-config-jest', + ], + + parser: '@typescript-eslint/parser', // the TypeScript parser we installed earlier + + parserOptions: { + ecmaVersion: 'latest', + project: './tsconfig.eslint.json', + }, + + settings: { + 'import/resolver': { + node: { + extensions: ['.js', '.ts'], + }, + }, + }, + + plugins: ['promise', '@typescript-eslint', 'prettier'], + rules: { + // disable for `scripts` and `config` + '@typescript-eslint/no-var-requires': 'off', + // allow ++ in for loops + 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], + // disabled due to typescript + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': ['error', { allow: ['resolve', 'reject', 'done', 'next', 'error'] }], + // disabled due to typescript + 'no-use-before-define': 'off', + '@typescript-eslint/no-use-before-define': 'warn', + // We are using typescript, disable jsdoc rules + 'jsdoc/require-jsdoc': 'off', + 'jsdoc/require-returns': 'off', + 'jsdoc/require-param-type': 'off', + // allow reassign in properties + 'no-param-reassign': ['warn', { props: false }], + // support monorepos + 'import/no-extraneous-dependencies': ['error', { packageDir: ['./', '../../'] }], + }, +}; diff --git a/exporters/js/.gitignore b/exporters/js/.gitignore new file mode 100644 index 0000000000..b484745fd9 --- /dev/null +++ b/exporters/js/.gitignore @@ -0,0 +1,2 @@ +.build +.coverage diff --git a/exporters/js/CHANGELOG.md b/exporters/js/CHANGELOG.md new file mode 100644 index 0000000000..e4d87c4d45 --- /dev/null +++ b/exporters/js/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. diff --git a/exporters/js/CONTRIBUTING.md b/exporters/js/CONTRIBUTING.md new file mode 100644 index 0000000000..eeed7f2b9b --- /dev/null +++ b/exporters/js/CONTRIBUTING.md @@ -0,0 +1,18 @@ +# Contributing + +## Development + +Please, read the Supernova Documentation below to start developing this package. +You will need to install an extension to your IDE to start with development. + +Distribution file `generated/functions.js` is assembled by Vite and `build` script. Please, do not edit this file manually. +All files in `src` directory is editable and buildable by `build` script. + +❗ Please, run `build` script for every change you make and commit generated file with other changes. +Supernova Cloud loads exporters directly from GitHub repository. + +## Supernova Documentation + +- [Supernova - Function List](https://developers.supernova.io/latest/design-system-model/function-list.html#search-fb31ced2-ca07-11ec-885b-510a619c4a1b) +- [Supernova - How to build Exporters](https://developers.supernova.io/latest/building-exporters/overview-1.html) +- [Supernova - How to build Exporters using JavaScript](https://developers.supernova.io/latest/building-exporters/building-exporters-101/using-javascript.html) diff --git a/exporters/js/LICENSE.md b/exporters/js/LICENSE.md new file mode 100644 index 0000000000..a1eff20bb7 --- /dev/null +++ b/exporters/js/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 LMC s.r.o. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/exporters/js/README.md b/exporters/js/README.md new file mode 100644 index 0000000000..281e41800a --- /dev/null +++ b/exporters/js/README.md @@ -0,0 +1,44 @@ +# Exporter Spirit JS + +[Supernova][supernova-studio] JS exporter made for Spirit Design System developed by [LMC][lmc]. + +## Token operations + +This exported does several operations with tokens: + +- All token groups except [Typography](#typography) are processed using a simple generate function. +- The first step is sorting. Measures are sorted by number in the token name, Generic Tokens (Other) by value, and the rest by its name. +- Next, each token is grouped and its value is prepared to print. Grouping is made using actual groups in Supernova and if these are not present, a common name prefix is used. Separate token values are printed as separate variables. +- Groups are used for printing objects with references to separate tokens and pluralized names. +- Shadows are grouped if same name. +- If Gradient names start with `gradients/gradient`, they are not used from Figma, but from Supernova + +### Typography + +As typography in Figma and Supernova are stored in named text style groups, these groups are used to generate objects with all the values from Supernova. They are grouped by breakpoints. +⚠️ We do not generate `link` typography tokens (styles that include `-link` in their name). + +#### Ebony Font Weight Exception + +Font Family Ebony has a different font weight mapping in Figma and in Adobe Fonts. To match these we set its own font weight numeric-name conversion. + +### Sorting + +Tokens are sorted alphabetically by origin (Figma) name or by name (Supernova). Except Measures - sorted by name number and Other - sorted by value. + +## Outputs: + +- borders.ts +- colors.ts +- gradients.ts +- measures.ts +- other.ts +- radii.ts +- shadows.ts +- typography.ts +- index.ts + +The index file contains exports from all other outputs. + +[supernova-studio]: https://github.com/Supernova-Studio +[lmc]: https://github.com/lmc-eu diff --git a/exporters/js/exporter.json b/exporters/js/exporter.json new file mode 100644 index 0000000000..2343e33d6b --- /dev/null +++ b/exporters/js/exporter.json @@ -0,0 +1,48 @@ +{ + "id": "eu.lmc.exporter-spirit-js", + "name": "Spirit JS Exporter", + "description": "Spirit JS Exporter", + "author": "Jan Kryšpín ", + "organization": "LMC s.r.o.", + "source_dir": "src", + "version": "1.0.0", + "usesBrands": true, + "config": { + "sources": "sources.json", + "output": "output.json", + "js": "generated/functions.js" + }, + "engines": { + "pulsar": "1.0.0", + "supernova": "1.0.0" + }, + "tags": ["JS", "Tokens", "Styles", "Spirit", "TS"], + "contributes": { + "configuration": [ + { + "key": "defaultFontSize", + "default": 16, + "type": "number", + "label": "Default project font-size in px", + "description": "Used for calculation to rem", + "category": "Advanced" + }, + { + "key": "fontFamilyFallback", + "default": ", sans-serif", + "type": "string", + "label": "Font Family fallback", + "description": "Font Family fallback", + "category": "Advanced" + }, + { + "key": "breakpoints", + "default": "mobile,tablet,desktop", + "type": "string", + "label": "Breakpoints", + "description": "List available breakpoints. Separate them with comma. Example and default value: 'mobile,tablet,desktop'.", + "category": "Advanced" + } + ] + } +} diff --git a/exporters/js/generated/functions.js b/exporters/js/generated/functions.js new file mode 100644 index 0000000000..d0e1f55955 --- /dev/null +++ b/exporters/js/generated/functions.js @@ -0,0 +1,33 @@ +"use strict";function T(t){return t.replace(/\s/g,"-").replace(/\//g,"-").replace(/-\d\d-/g,"-").replace(/--+/g,"-").toLowerCase()}function E(t){return t.split("-").map(n=>n.charAt(0).toUpperCase()+n.slice(1).toLowerCase()).join("")}function C(t){return t.split("-").map((n,r)=>r>0?n.charAt(0).toUpperCase()+n.slice(1).toLowerCase():n).join("")}function O(t){return t.slice(-1)==="s"?t.replace(/.$/,""):t}function H(t,n){let r="";const l=[];return Object.entries(t).forEach(([$,o])=>{const g=C($);l.push(g),o.length>0&&(r=`${r} +export const ${g} = { +${o.map(m=>` ${C(m)}: ${C($)}${E(m)},`).join(` +`)} +}; +`)}),l.length>0&&(r=`${r} +export const ${n} = { + ${l.join(`, + `)}, +}; +`),r}function d(t,n){let r=t.toString();return+t!=0&&(n==="Pixels"&&(r+="px"),n==="rem"&&(r+="rem")),r}function L(t){const n=t.match(/.{1,2}/g);let r=!0;return n&&n.forEach(l=>{r&&(r=/^(.)\1+$/.test(l))}),r?`${t.substring(0,1)}${t.substring(2,3)}${t.substring(4,5)}`:t}function W(t){return t.a<255?`#${t.hex}`:`#${L(t.hex.substring(0,6))}`}function M(t,n){const r=n.y-t.y,l=n.x-t.x;let o=Math.atan2(r,l)*180/Math.PI;return o+=90,(o<0?360+o:o)%360}function b(t,n){const r=T(t.origin?t.origin.name:t.name),l=T(n.origin?n.origin.name:n.name);return r.localeCompare(l)}function D(t,n){return+t.value.text-+n.value.text}function z(t,n,r,l){const $=r.trim().split(",");let o="",g="";if($.some(c=>((t.origin?t.origin.name:t.name).includes(c)&&(o=c),(n.origin?n.origin.name:n.name).includes(c)&&(g=c),!1)),l&&!o)return-1;let m=$.indexOf(o)-$.indexOf(g);return m===0&&(m=b(t,n)),m}function F(t){return t.name.match(/ \d$/)}function G(t,n,r=[],l=!1,$=!1,o="",g=""){let m=n.sort((e,a)=>{if(l){const p=e.name.match(/\d+$/),i=a.name.match(/\d+$/);if(p&&i)return parseInt(p[0],10)-parseInt(i[0],10)}return $?D(e,a):b(e,a)});o.length>0&&(m=n.sort((e,a)=>z(e,a,o,$)));const c=[],v={};m.forEach(e=>{if(g.length>0&&!e.name.startsWith(g))return;let a=T(e.name);e.origin&&!e.origin.name.toLowerCase().startsWith("gradients/gradient")&&(a=T(e.origin.name));const p=F(e);let i="";!p&&r.length>0&&r.forEach(f=>{Object.values(f.tokenIds).indexOf(e.id)>-1&&f.isRoot===!1&&(i=O(T(f.name)))});const x=a.split("-"),h=i===""?x[0]:i,S=i===""?a.replace(`${x[0]}-`,""):a.replace(`${i}-`,"");v[h]&&v[h].length>0?!p&&x[0]!==a?v[h].push(S):v[h]=[]:!p&&x[0]!==a?v[h]=[S]:v[h]=[];let u="";if(e.tokenType==="Color")u=W(e.value);else if(e.tokenType==="Radius")u=d(e.value.radius.measure,e.value.radius.unit);else if(e.tokenType==="GenericToken")e.propertyValues.unitless?u=e.value.text:u=d(e.value.text,"Pixels");else if(e.tokenType==="Shadow")u=`${d(e.value.x.measure,e.value.x.unit)} ${d(e.value.y.measure,e.value.y.unit)} ${d(e.value.radius.measure,e.value.radius.unit)} ${d(e.value.spread.measure,e.value.spread.unit)} ${W(e.value.color)}`;else if(e.tokenType==="Gradient"){let f="linear-gradient",P=`${Math.round(M(e.value.from,e.value.to)*100)/100}deg`;e.value.type==="Radial"&&(f="radial-gradient",P="circle at center"),u=`${f}(var(--angle, ${P}), ${e.value.stops.map(y=>`${W(y.color)} ${Math.round(y.position*10)/10*100}%`).join(", ")})`}else e.tokenType==="Border"?u=e.propertyValues.style??d(e.value.width.measure,e.value.width.unit):u=d(e.value.measure,e.value.unit);if(p){const f=T(e.name.replace(/ \d$/,"")),P=c.filter(j=>j.startsWith(`$${f}: `))[0],y=c.indexOf(P);y>-1&&(c[y]=c[y].replace(/= '(.*)';/g,`= '${u}, $1';`))}else c.push(`export const ${C(a)} = ${u==="0"?0:`'${u}'`};`)});const s=m.length===0?"":H(v,t);return`${c.join(` +`)} +${s}`}const V={400:300,600:400};function A(t,n,r,l=""){const $=t.sort(b),o=l.trim().split(","),g={};$.forEach(s=>{var w;const e=T(((w=s.origin)==null?void 0:w.name)||s.name);let a=e,p=o[0];o.forEach(I=>{a.includes(I)&&(p=I,a=a.replace(`-${I}`,""))});const i=d(Math.round(s.value.fontSize.measure/n*1e3)/1e3,"rem");let x="normal",h=+s.value.font.subfamily;s.value.font.family==="Ebony"&&(h=V[h]),e.includes("italic")&&(x="italic");const S=s.value.lineHeight&&Math.round(s.value.lineHeight.measure/100*1e3)/1e3,u=d(s.value.letterSpacing.measure,s.value.letterSpacing.unit),f=s.value.textDecoration.toLowerCase(),P=d(s.value.paragraphIndent.measure,s.value.paragraphIndent.unit),y=s.value.textCase==="Original"?"none":s.value.textCase.toLowerCase(),j={fontFamily:`'${s.value.font.family}'${r}`,fontSize:i,fontStyle:x,fontWeight:h,lineHeight:S,letterSpacing:u,textDecoration:f,paragraphIndent:P,textTransform:y};typeof g[a]<"u"?g[a][p]=j:g[a]={[p]:j}});const m=[],c=[];Object.entries(g).forEach(([s,e])=>{if(s.includes("-link"))return;c.push(`${C(s)}: ${C(s)},`);const a=[];o.forEach(p=>{const i=e[p];if(typeof i<"u"){const x=i.lineHeight?` + lineHeight: ${i.lineHeight},`:"",h=i.letterSpacing!=="0"?` + letterSpacing: ${i.letterSpacing},`:"",S=i.textDecoration!=="none"?` + textDecoration: ${i.textDecoration},`:"",u=i.paragraphIndent!=="0"?` + textIndent: ${i.paragraphIndent},`:"",f=i.textTransform!=="none"?` + textTransform: ${i.textTransform},`:"";a.push(`${p}: { + fontFamily: "${i.fontFamily}", + fontSize: '${i.fontSize}', + fontStyle: '${i.fontStyle}', + fontWeight: ${i.fontWeight},${x}${h}${S}${u}${f} + },`)}}),m.push(`export const ${C(s)} = { + ${a.join(` + `)} +}; +`)});const v=`export const styles = { + ${c.join(` + `)} +};`;return`${m.join(` +`)} +${v} +`}Pulsar.registerFunction("generateSimple",G);Pulsar.registerFunction("generateTypography",A); diff --git a/exporters/js/jest.config.js b/exporters/js/jest.config.js new file mode 100644 index 0000000000..dc025533ea --- /dev/null +++ b/exporters/js/jest.config.js @@ -0,0 +1,49 @@ +const config = { + // The root directory that Jest should scan for tests and modules within. + // https://jestjs.io/docs/configuration#rootdir-string + rootDir: './', + + // This option tells Jest that all imported modules in your tests should be mocked automatically. + // https://jestjs.io/docs/configuration#automock-boolean + automock: false, + + // Indicates whether each individual test should be reported during the run. + // https://jestjs.io/docs/configuration#verbose-boolean + verbose: false, + + // A map from regular expressions to paths to transformers + // https://jestjs.io/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object + transform: { + '^.+\\.(t|j)s?$': ['/../../node_modules/@swc/jest'], + }, + + // The test environment that will be used for testing. + // https://jestjs.io/docs/configuration#testenvironment-string + // testEnvironment: 'jsdom', + + // An array of regexp pattern strings that are matched against all test paths before executing the test + // https://jestjs.io/docs/configuration#testpathignorepatterns-arraystring + testPathIgnorePatterns: ['/dist/', '/node_modules/', '.*__tests__/.*DataProvider.ts'], + + // The directory where Jest should output its coverage files. + // https://jestjs.io/docs/configuration#coveragedirectory-string + coverageDirectory: './.coverage', + + // An array of glob patterns indicating a set of files for which coverage information should be collected. + // https://jestjs.io/docs/configuration#collectcoveragefrom-array + collectCoverageFrom: ['/src/**/*.{js,ts}', '!/src/**/*.d.ts'], + + // An array of regexp pattern strings that are matched against all file paths before executing the test. + // https://jestjs.io/docs/configuration#coveragepathignorepatterns-arraystring + coveragePathIgnorePatterns: ['__fixtures__'], + + // A list of reporter names that Jest uses when writing coverage reports. Any istanbul reporter can be used. + // https://jestjs.io/docs/configuration#coveragereporters-arraystring--string-options + coverageReporters: ['text', 'text-summary', ['lcov', { projectRoot: '../../' }]], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test. + // https://jestjs.io/docs/configuration#setupfilesafterenv-array + // setupFilesAfterEnv: ['@testing-library/jest-dom'], +}; + +module.exports = config; diff --git a/exporters/js/output.json b/exporters/js/output.json new file mode 100644 index 0000000000..863b90c253 --- /dev/null +++ b/exporters/js/output.json @@ -0,0 +1,40 @@ +{ + "blueprints": [ + { + "invoke": "borders.pr", + "write_to": "borders.ts" + }, + { + "invoke": "colors.pr", + "write_to": "colors.ts" + }, + { + "invoke": "gradients.pr", + "write_to": "gradients.ts" + }, + { + "invoke": "measures.pr", + "write_to": "measures.ts" + }, + { + "invoke": "other.pr", + "write_to": "other.ts" + }, + { + "invoke": "radii.pr", + "write_to": "radii.ts" + }, + { + "invoke": "shadows.pr", + "write_to": "shadows.ts" + }, + { + "invoke": "typography.pr", + "write_to": "typography.ts" + }, + { + "invoke": "index.pr", + "write_to": "index.ts" + } + ] +} diff --git a/exporters/js/package.json b/exporters/js/package.json new file mode 100644 index 0000000000..bde1d82525 --- /dev/null +++ b/exporters/js/package.json @@ -0,0 +1,24 @@ +{ + "name": "@lmc-eu/spirit-exporters-js", + "version": "1.0.0", + "description": "Spirit JS Exporter for Supernova", + "license": "MIT", + "private": true, + "scripts": { + "build": "vite build", + "lint": "eslint ./", + "lint:fix": "yarn lint --fix", + "test": "npm-run-all lint test:unit:coverage types", + "test:unit": "jest", + "test:unit:watch": "yarn test:unit --watchAll", + "test:unit:coverage": "yarn test:unit --coverage", + "types": "tsc" + }, + "devDependencies": { + "@swc/core": "1.3.94", + "@swc/jest": "0.2.29", + "jest": "29.7.0", + "typescript": "4.9.5", + "vite": "4.5.0" + } +} diff --git a/exporters/js/sources.json b/exporters/js/sources.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/exporters/js/sources.json @@ -0,0 +1 @@ +{} diff --git a/exporters/js/src/borders.pr b/exporters/js/src/borders.pr new file mode 100644 index 0000000000..11e3b29cd1 --- /dev/null +++ b/exporters/js/src/borders.pr @@ -0,0 +1,4 @@ +// Generated Borders from Supernova. Do not edit manually. +{[ if (ds.tokensByType("Border", ds.currentBrand().id)).count() > 0 ]} +{{ generateSimple("borders", ds.tokensByType("Border", ds.currentBrand().id), ds.tokenGroupsOfType("Border", ds.currentBrand().id)) }} +{[/]} diff --git a/exporters/js/src/colors.pr b/exporters/js/src/colors.pr new file mode 100644 index 0000000000..ef63522fb2 --- /dev/null +++ b/exporters/js/src/colors.pr @@ -0,0 +1,4 @@ +// Generated Colors from Supernova. Do not edit manually. +{[ if (ds.tokensByType("Color", ds.currentBrand().id)).count() > 0 ]} +{{ generateSimple("colors", ds.tokensByType("Color", ds.currentBrand().id)) }} +{[/]} diff --git a/exporters/js/src/gradients.pr b/exporters/js/src/gradients.pr new file mode 100644 index 0000000000..2f6905bc35 --- /dev/null +++ b/exporters/js/src/gradients.pr @@ -0,0 +1,4 @@ +// Generated Gradients from Supernova. Do not edit manually. +{[ if (ds.tokensByType("Gradient", ds.currentBrand().id)).count() > 0 ]} +{{ generateSimple("gradients", ds.tokensByType("Gradient", ds.currentBrand().id)) }} +{[/]} diff --git a/exporters/js/src/index.pr b/exporters/js/src/index.pr new file mode 100644 index 0000000000..c3bd7c48cc --- /dev/null +++ b/exporters/js/src/index.pr @@ -0,0 +1,8 @@ +export * from './borders'; +export * from './colors'; +export * from './gradients'; +export * from './measures'; +export * from './other'; +export * from './radii'; +export * from './shadows'; +export * from './typography'; diff --git a/exporters/js/src/js/formatters/__tests__/color.test.ts b/exporters/js/src/js/formatters/__tests__/color.test.ts new file mode 100644 index 0000000000..df541b755d --- /dev/null +++ b/exporters/js/src/js/formatters/__tests__/color.test.ts @@ -0,0 +1,10 @@ +import { formatColor } from '../color'; + +describe('formatColor', () => { + it.each([ + // name, expected + [{ a: 125, hex: '123456' }, '#123456'], + ])('should format color', (color, expected) => { + expect(formatColor(color)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/formatters/color.ts b/exporters/js/src/js/formatters/color.ts new file mode 100644 index 0000000000..81164de7b6 --- /dev/null +++ b/exporters/js/src/js/formatters/color.ts @@ -0,0 +1,14 @@ +import { normalizeColor } from '../normalizers/color'; + +type ColorShape = { + a: number; + hex: string; +}; + +export function formatColor(color: ColorShape): string { + if (color.a < 255) { + return `#${color.hex}`; + } + + return `#${normalizeColor(color.hex.substring(0, 6))}`; +} diff --git a/exporters/js/src/js/generators/__fixtures__/simpleTokens.json b/exporters/js/src/js/generators/__fixtures__/simpleTokens.json new file mode 100644 index 0000000000..d68cdc7178 --- /dev/null +++ b/exporters/js/src/js/generators/__fixtures__/simpleTokens.json @@ -0,0 +1,40 @@ +[ + { + "id": "149fc92b-8586-11eb-a324-c7f25166e00c", + "name": "10", + "description": "10", + "tokenType": "Color", + "origin": { + "source": "Figma", + "id": "S:494296a45a5072718577cc0faae3bd89e6c47207,", + "name": "Base/Pink/10" + }, + "value": { + "hex": "fef0f5ff", + "r": 254, + "g": 240, + "b": 245, + "a": 255, + "referencedToken": null + } + }, + { + "id": "149fc92c-8586-11eb-a324-c7f25166e00c", + "name": "20", + "description": "20", + "tokenType": "Color", + "origin": { + "source": "Figma", + "id": "S:5642100401a4019c765b51f22f0f203de2cd1a02,", + "name": "Base/Pink/20" + }, + "value": { + "hex": "fdd5e4ff", + "r": 253, + "g": 213, + "b": 228, + "a": 255, + "referencedToken": null + } + } +] diff --git a/exporters/js/src/js/generators/__fixtures__/typographyTokens.json b/exporters/js/src/js/generators/__fixtures__/typographyTokens.json new file mode 100644 index 0000000000..fa21419982 --- /dev/null +++ b/exporters/js/src/js/generators/__fixtures__/typographyTokens.json @@ -0,0 +1,98 @@ +[ + { + "id": "149fc92b-8586-11eb-a324-c7f25166e00c", + "name": "Text-Light", + "description": "Text-Light", + "tokenType": "Typography", + "origin": { + "source": "Figma", + "id": "S:494296a45a5072718577cc0faae3bd89e6c47207,", + "name": "Body/Medium/Text-Light" + }, + "value": { + "font": { + "family": "Test", + "subfamily": "300" + }, + "fontSize": { + "measure": 10 + }, + "lineHeight": { + "measure": 10 + }, + "letterSpacing": { + "measure": 10 + }, + "textDecoration": "none", + "paragraphIndent": { + "measure": 10 + }, + "textCase": "Original", + "referencedToken": null + } + }, + { + "id": "149fc92c-8586-11eb-a324-c7f25166e00c", + "name": "Text-Regular", + "description": "Text-Regular", + "tokenType": "Typography", + "origin": { + "source": "Figma", + "id": "S:5642100401a4019c765b51f22f0f203de2cd1a02,", + "name": "Body/Medium/Text-Regular" + }, + "value": { + "font": { + "family": "Test", + "subfamily": "400" + }, + "fontSize": { + "measure": 10 + }, + "lineHeight": { + "measure": 10 + }, + "letterSpacing": { + "measure": 10 + }, + "textDecoration": "none", + "paragraphIndent": { + "measure": 10 + }, + "textCase": "Original", + "referencedToken": null + } + }, + { + "id": "149fc92c-8586-11eb-a324-c7f25166e00c", + "name": "Text-Italic", + "description": "Text-Italic", + "tokenType": "Typography", + "origin": { + "source": "Figma", + "id": "S:5642100401a4019c765b51f22f0f203de2cd1a02,", + "name": "Body/Medium/Text-Italic" + }, + "value": { + "font": { + "family": "Ebony", + "subfamily": "400" + }, + "fontSize": { + "measure": 10 + }, + "lineHeight": { + "measure": 10 + }, + "letterSpacing": { + "measure": 10 + }, + "textDecoration": "none", + "paragraphIndent": { + "measure": 10 + }, + "textCase": "Original", + "referencedToken": null + } + } +] diff --git a/exporters/js/src/js/generators/__tests__/__snapshots__/simple.test.ts.snap b/exporters/js/src/js/generators/__tests__/__snapshots__/simple.test.ts.snap new file mode 100644 index 0000000000..e8225882bd --- /dev/null +++ b/exporters/js/src/js/generators/__tests__/__snapshots__/simple.test.ts.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateSimple should generate simple output 1`] = ` +"export const basePink10 = '#fef0f5'; +export const basePink20 = '#fdd5e4'; + +export const base = { + pink10: basePink10, + pink20: basePink20, +}; + +export const test = { + base, +}; +" +`; diff --git a/exporters/js/src/js/generators/__tests__/__snapshots__/typography.test.ts.snap b/exporters/js/src/js/generators/__tests__/__snapshots__/typography.test.ts.snap new file mode 100644 index 0000000000..d1717247fd --- /dev/null +++ b/exporters/js/src/js/generators/__tests__/__snapshots__/typography.test.ts.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateTypography should generate simple output 1`] = ` +"export const bodyMediumTextItalic = { + mobile: { + fontFamily: "'Ebony', sans-serif", + fontSize: '1rem', + fontStyle: 'italic', + fontWeight: 300, + lineHeight: 0.1, + letterSpacing: 10, + textIndent: 10, + }, +}; + +export const bodyMediumTextLight = { + mobile: { + fontFamily: "'Test', sans-serif", + fontSize: '1rem', + fontStyle: 'normal', + fontWeight: 300, + lineHeight: 0.1, + letterSpacing: 10, + textIndent: 10, + }, +}; + +export const bodyMediumTextRegular = { + mobile: { + fontFamily: "'Test', sans-serif", + fontSize: '1rem', + fontStyle: 'normal', + fontWeight: 400, + lineHeight: 0.1, + letterSpacing: 10, + textIndent: 10, + }, +}; + +export const styles = { + bodyMediumTextItalic: bodyMediumTextItalic, + bodyMediumTextLight: bodyMediumTextLight, + bodyMediumTextRegular: bodyMediumTextRegular, +}; +" +`; diff --git a/exporters/js/src/js/generators/__tests__/simple.test.ts b/exporters/js/src/js/generators/__tests__/simple.test.ts new file mode 100644 index 0000000000..f2a5f74240 --- /dev/null +++ b/exporters/js/src/js/generators/__tests__/simple.test.ts @@ -0,0 +1,9 @@ +import { Token } from '../../index'; +import { generateSimple } from '../simple'; +import simpleTokens from '../__fixtures__/simpleTokens.json'; + +describe('generateSimple', () => { + it.each([[simpleTokens]])('should generate simple output', (allTokens: Array) => { + expect(generateSimple('test', allTokens)).toMatchSnapshot(); + }); +}); diff --git a/exporters/js/src/js/generators/__tests__/typography.test.ts b/exporters/js/src/js/generators/__tests__/typography.test.ts new file mode 100644 index 0000000000..625ed6c221 --- /dev/null +++ b/exporters/js/src/js/generators/__tests__/typography.test.ts @@ -0,0 +1,8 @@ +import { generateTypography } from '../typography'; +import typographyTokens from '../__fixtures__/typographyTokens.json'; + +describe('generateTypography', () => { + it.each([[typographyTokens]])('should generate simple output', (allTokens) => { + expect(generateTypography(allTokens, '10', ', sans-serif', 'mobile,tablet,desktop')).toMatchSnapshot(); + }); +}); diff --git a/exporters/js/src/js/generators/simple.ts b/exporters/js/src/js/generators/simple.ts new file mode 100644 index 0000000000..a40be6af04 --- /dev/null +++ b/exporters/js/src/js/generators/simple.ts @@ -0,0 +1,148 @@ +// Do not want to deal with exact shape of Token +// @TODO: https://github.com/lmc-eu/spirit-design-system/issues/470 +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +import { kebabCaseToCamelCase, slugifyName } from '../normalizers/names'; +import { singular } from '../normalizers/singular'; +import { printTypes } from '../printers/types'; +import { printUnit } from '../printers/unit'; +import { formatColor } from '../formatters/color'; +import { normalizeGradientAngle } from '../normalizers/gradients'; +import { localeSort } from '../sorters/localeSort'; +import { valueSort } from '../sorters/valueSort'; +import { breakpointSort } from '../sorters/breakpointSort'; +import { Token } from '..'; + +function isGroupToken(token: Token): boolean { + // Check if token is a group token, because it has a single digit at the end of the name + return token.name.match(/ \d$/); +} + +export function generateSimple( + globalName, + allTokens: Array, + groups = [], + sortByNum = false, + sortByValue = false, + breakpointsString = '', + skipByName = '', +): string { + let tokens = allTokens.sort((a, b) => { + if (sortByNum) { + const aNumMatch = a.name.match(/\d+$/); + const bNumMatch = b.name.match(/\d+$/); + + if (aNumMatch && bNumMatch) { + return parseInt(aNumMatch[0], 10) - parseInt(bNumMatch[0], 10); + } + } + + if (sortByValue) { + return valueSort(a, b); + } + + return localeSort(a, b); + }); + + if (breakpointsString.length > 0) { + tokens = allTokens.sort((a, b) => { + return breakpointSort(a, b, breakpointsString, sortByValue); + }); + } + + const vars: string[] = []; + const types = {}; + tokens.forEach((token) => { + if (skipByName.length > 0 && !token.name.startsWith(skipByName)) { + return; + } + // Get correct name of token + let name = slugifyName(token.name); + + // The Gradients exception is temporary, until JDS fix their naming + if (token.origin && !token.origin.name.toLowerCase().startsWith('gradients/gradient')) { + name = slugifyName(token.origin.name); + } + + const groupToken = isGroupToken(token); + + // Set token types + let groupName = ''; + if (!groupToken && groups.length > 0) { + groups.forEach((group: Token) => { + if (Object.values(group.tokenIds).indexOf(token.id) > -1 && group.isRoot === false) { + groupName = singular(slugifyName(group.name)); + } + }); + } + + const split = name.split('-'); + const typeName = groupName === '' ? split[0] : groupName; + const tokenNameWithoutType = + groupName === '' ? name.replace(`${split[0]}-`, '') : name.replace(`${groupName}-`, ''); + if (types[typeName] && types[typeName].length > 0) { + if (!groupToken && split[0] !== name) { + types[typeName].push(tokenNameWithoutType); + } else { + types[typeName] = []; + } + } else if (!groupToken && split[0] !== name) { + types[typeName] = [tokenNameWithoutType]; + } else { + types[typeName] = []; + } + + // Set value + let value = ''; + if (token.tokenType === 'Color') { + value = formatColor(token.value); + } else if (token.tokenType === 'Radius') { + value = printUnit(token.value.radius.measure, token.value.radius.unit); + } else if (token.tokenType === 'GenericToken') { + const unitlessProp = token.propertyValues.unitless; + if (unitlessProp) { + value = token.value.text; + } else { + value = printUnit(token.value.text, 'Pixels'); + } + } else if (token.tokenType === 'Shadow') { + value = `${printUnit(token.value.x.measure, token.value.x.unit)} ${printUnit( + token.value.y.measure, + token.value.y.unit, + )} ${printUnit(token.value.radius.measure, token.value.radius.unit)} ${printUnit( + token.value.spread.measure, + token.value.spread.unit, + )} ${formatColor(token.value.color)}`; + } else if (token.tokenType === 'Gradient') { + let gradientType = 'linear-gradient'; + let gradientDirection = `${Math.round(normalizeGradientAngle(token.value.from, token.value.to) * 100) / 100}deg`; + if (token.value.type === 'Radial') { + gradientType = 'radial-gradient'; + gradientDirection = 'circle at center'; + } + value = `${gradientType}(var(--angle, ${gradientDirection}), ${token.value.stops + .map((stop) => `${formatColor(stop.color)} ${(Math.round(stop.position * 10) / 10) * 100}%`) + .join(', ')})`; + } else if (token.tokenType === 'Border') { + const styleProp = token.propertyValues.style; + value = styleProp ?? printUnit(token.value.width.measure, token.value.width.unit); + } else { + value = printUnit(token.value.measure, token.value.unit); + } + + if (groupToken) { + const nameWithoutGroup = slugifyName(token.name.replace(/ \d$/, '')); + const groupOriginal = vars.filter((item) => item.startsWith(`$${nameWithoutGroup}: `))[0]; + const index = vars.indexOf(groupOriginal); + if (index > -1) { + vars[index] = vars[index].replace(/= '(.*)';/g, `= '${value}, $1';`); + } + } else { + vars.push(`export const ${kebabCaseToCamelCase(name)} = ${value === '0' ? 0 : `'${value}'`};`); + } + }); + + const typesPrint = tokens.length === 0 ? '' : printTypes(types, globalName); + + return `${vars.join('\n')}\n${typesPrint}`; +} diff --git a/exporters/js/src/js/generators/typography.ts b/exporters/js/src/js/generators/typography.ts new file mode 100644 index 0000000000..2aa20eeee2 --- /dev/null +++ b/exporters/js/src/js/generators/typography.ts @@ -0,0 +1,113 @@ +// Do not want to deal with exact shape of Token +// @TODO: https://github.com/lmc-eu/spirit-design-system/issues/470 +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +import { kebabCaseToCamelCase, slugifyName } from '../normalizers/names'; +import { printUnit } from '../printers/unit'; +import { localeSort } from '../sorters/localeSort'; +import { Token } from '..'; + +const ebonyFontWeights = { + 400: 300, + 600: 400, +}; + +export function generateTypography( + allTokens: Array, + defaultFontSize: string, + fontFamilyFallback: string, + breakpointsString = '', +) { + const tokens = allTokens.sort(localeSort); + + const breakpoints = breakpointsString.trim().split(','); + const styles = {}; + tokens.forEach((token) => { + const name = slugifyName(token.origin?.name || token.name); + let nameWithoutBreakpoint = name; + let breakpoint = breakpoints[0]; + breakpoints.forEach((bp) => { + if (nameWithoutBreakpoint.includes(bp)) { + breakpoint = bp; + nameWithoutBreakpoint = nameWithoutBreakpoint.replace(`-${bp}`, ''); + } + }); + + const fontSize = printUnit(Math.round((token.value.fontSize.measure / defaultFontSize) * 1000) / 1000, 'rem'); + let fontStyle = 'normal'; + let fontWeight = +token.value.font.subfamily; + + // Font Ebony has a different font weight mapping, so we remap these values directly + if (token.value.font.family === 'Ebony') { + fontWeight = ebonyFontWeights[fontWeight]; + } + + // TODO: This is a hack to get around the fact that I don't know how to check if font is italic in JS + if (name.includes('italic')) { + fontStyle = 'italic'; + } + + const lineHeight = token.value.lineHeight && Math.round((token.value.lineHeight.measure / 100) * 1000) / 1000; + const letterSpacing = printUnit(token.value.letterSpacing.measure, token.value.letterSpacing.unit); + const textDecoration = token.value.textDecoration.toLowerCase(); + const paragraphIndent = printUnit(token.value.paragraphIndent.measure, token.value.paragraphIndent.unit); + const textTransform = token.value.textCase === 'Original' ? 'none' : token.value.textCase.toLowerCase(); + const tokenVals = { + fontFamily: `'${token.value.font.family}'${fontFamilyFallback}`, + fontSize, + fontStyle, + fontWeight, + lineHeight, + letterSpacing, + textDecoration, + paragraphIndent, + textTransform, + }; + + if (typeof styles[nameWithoutBreakpoint] !== 'undefined') { + styles[nameWithoutBreakpoint][breakpoint] = tokenVals; + } else { + styles[nameWithoutBreakpoint] = { + [breakpoint]: tokenVals, + }; + } + }); + + const vars = []; + const list = []; + Object.entries(styles).forEach(([styleName, styleBreakpoints]) => { + if (styleName.includes('-link')) { + return; + } + list.push(`${kebabCaseToCamelCase(styleName)}: ${kebabCaseToCamelCase(styleName)},`); + const breakpointValues = []; + breakpoints.forEach((breakpoint) => { + const breakpointVal = styleBreakpoints[breakpoint]; + if (typeof breakpointVal !== 'undefined') { + const printLineHeight = breakpointVal.lineHeight ? `\n lineHeight: ${breakpointVal.lineHeight},` : ''; + const printLetterSpacing = + breakpointVal.letterSpacing !== '0' ? `\n letterSpacing: ${breakpointVal.letterSpacing},` : ''; + const printTextDecoration = + breakpointVal.textDecoration !== 'none' ? `\n textDecoration: ${breakpointVal.textDecoration},` : ''; + const printParagraphIndent = + breakpointVal.paragraphIndent !== '0' ? `\n textIndent: ${breakpointVal.paragraphIndent},` : ''; + const printTextTransform = + breakpointVal.textTransform !== 'none' ? `\n textTransform: ${breakpointVal.textTransform},` : ''; + breakpointValues.push(`${breakpoint}: { + fontFamily: "${breakpointVal.fontFamily}", + fontSize: '${breakpointVal.fontSize}', + fontStyle: '${breakpointVal.fontStyle}', + fontWeight: ${breakpointVal.fontWeight},${printLineHeight}${printLetterSpacing}${printTextDecoration}${printParagraphIndent}${printTextTransform} + },`); + } + }); + vars.push(`export const ${kebabCaseToCamelCase(styleName)} = { + ${breakpointValues.join('\n ')} +};\n`); + }); + const listPrint = `export const styles = { + ${list.join('\n ')} +};`; + + return `${vars.join('\n')}\n${listPrint}\n`; +} diff --git a/exporters/js/src/js/index.ts b/exporters/js/src/js/index.ts new file mode 100644 index 0000000000..c56915dfb3 --- /dev/null +++ b/exporters/js/src/js/index.ts @@ -0,0 +1,30 @@ +import { generateSimple } from './generators/simple'; +import { generateTypography } from './generators/typography'; + +export interface Origin { + source: string; + id: string; + name: string; +} + +export interface Token { + id: string; + name: string; + description: string; + tokenType: string; + origin?: Origin; + value: Record; + isRoot?: boolean; + path?: Array; + tokenIds?: Array; + subgroups?: Record; +} + +// Pulsar is global-scope object of Supernova, accesible only inside the platform +// @see: https://developers.supernova.io/building-exporters/creating-new-exporter/using-javascript +// eslint-disable-next-line no-undef +// @ts-expect-error TS2304: Cannot find name 'Pulsar'. +Pulsar.registerFunction('generateSimple', generateSimple); +// eslint-disable-next-line no-undef +// @ts-expect-error TS2304: Cannot find name 'Pulsar'. +Pulsar.registerFunction('generateTypography', generateTypography); diff --git a/exporters/js/src/js/normalizers/__tests__/color.test.js b/exporters/js/src/js/normalizers/__tests__/color.test.js new file mode 100644 index 0000000000..11f5a9ea22 --- /dev/null +++ b/exporters/js/src/js/normalizers/__tests__/color.test.js @@ -0,0 +1,10 @@ +import { normalizeColor } from '../color'; + +describe('normalizeColor', () => { + it.each([ + // name, expected + ['123456', '123456'], + ])('should normalize color', (color, expected) => { + expect(normalizeColor(color)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/normalizers/__tests__/gradients.test.js b/exporters/js/src/js/normalizers/__tests__/gradients.test.js new file mode 100644 index 0000000000..95c1922f45 --- /dev/null +++ b/exporters/js/src/js/normalizers/__tests__/gradients.test.js @@ -0,0 +1,10 @@ +import { normalizeGradientAngle } from '../gradients'; + +describe('normalizeGradientAngle', () => { + it.each([ + // from, to, expected + [{ x: 0, y: 100 }, { x: 100, y: 0 }, 45], + ])('should narmalize gradient angle', (from, to, expected) => { + expect(normalizeGradientAngle(from, to)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/normalizers/__tests__/names.test.js b/exporters/js/src/js/normalizers/__tests__/names.test.js new file mode 100644 index 0000000000..ff12a586f2 --- /dev/null +++ b/exporters/js/src/js/normalizers/__tests__/names.test.js @@ -0,0 +1,36 @@ +import { slugifyName, kebabCaseToPascalCase, kebabCaseToCamelCase } from '../names'; + +describe('slugifyName', () => { + it.each([ + ['test', 'test'], + ['test--test', 'test-test'], + ['test test', 'test-test'], + ['test--12--TEST', 'test-test'], + ['Text Primary', 'text-primary'], + ['Text/Primary', 'text-primary'], + ['Text--Primary', 'text-primary'], + ['Text-01-Primary', 'text-primary'], + ])('should slugify name correctly', (name, expected) => { + expect(slugifyName(name)).toBe(expected); + }); +}); + +describe('kebabCaseToPascalCase', () => { + it.each([ + ['test', 'Test'], + ['test-test', 'TestTest'], + ['text-primary', 'TextPrimary'], + ])('should convert kebab case to pascal case', (name, expected) => { + expect(kebabCaseToPascalCase(name)).toBe(expected); + }); +}); + +describe('kebabCaseToCamelCase', () => { + it.each([ + ['test', 'test'], + ['test-test', 'testTest'], + ['text-primary', 'textPrimary'], + ])('should convert kebab case to camel case', (name, expected) => { + expect(kebabCaseToCamelCase(name)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/normalizers/__tests__/plural.test.js b/exporters/js/src/js/normalizers/__tests__/plural.test.js new file mode 100644 index 0000000000..b80406dc01 --- /dev/null +++ b/exporters/js/src/js/normalizers/__tests__/plural.test.js @@ -0,0 +1,10 @@ +import { plural } from '../plural'; + +describe('plural', () => { + it.each([ + // name, expected + ['color', 'colors'], + ])('should pluralize name', (name, expected) => { + expect(plural(name)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/normalizers/__tests__/singular.test.js b/exporters/js/src/js/normalizers/__tests__/singular.test.js new file mode 100644 index 0000000000..a7f3fc31c4 --- /dev/null +++ b/exporters/js/src/js/normalizers/__tests__/singular.test.js @@ -0,0 +1,10 @@ +import { singular } from '../singular'; + +describe('singular', () => { + it.each([ + // name, expected + ['colors', 'color'], + ])('should singularize name', (name, expected) => { + expect(singular(name)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/normalizers/color.ts b/exporters/js/src/js/normalizers/color.ts new file mode 100644 index 0000000000..27b18e4b8d --- /dev/null +++ b/exporters/js/src/js/normalizers/color.ts @@ -0,0 +1,16 @@ +export function normalizeColor(color: string): string { + const colorParts = color.match(/.{1,2}/g); + let shortHex = true; + colorParts && + colorParts.forEach((part) => { + if (shortHex) { + shortHex = /^(.)\1+$/.test(part); + } + }); + + if (shortHex) { + return `${color.substring(0, 1)}${color.substring(2, 3)}${color.substring(4, 5)}`; + } + + return color; +} diff --git a/exporters/js/src/js/normalizers/gradients.ts b/exporters/js/src/js/normalizers/gradients.ts new file mode 100644 index 0000000000..2fbd6cd561 --- /dev/null +++ b/exporters/js/src/js/normalizers/gradients.ts @@ -0,0 +1,14 @@ +type AngleShape = { + x: number; + y: number; +}; + +export function normalizeGradientAngle(from: AngleShape, to: AngleShape): number { + const deltaY = to.y - from.y; + const deltaX = to.x - from.x; + const radians = Math.atan2(deltaY, deltaX); + let result = (radians * 180) / Math.PI; + result += 90; + + return (result < 0 ? 360 + result : result) % 360; +} diff --git a/exporters/js/src/js/normalizers/names.ts b/exporters/js/src/js/normalizers/names.ts new file mode 100644 index 0000000000..c14960d8e1 --- /dev/null +++ b/exporters/js/src/js/normalizers/names.ts @@ -0,0 +1,39 @@ +export function slugifyName(name: string): string { + return ( + name + // Replace all white space characters with dashes, `Text Primary` -> `Text-Primary` + .replace(/\s/g, '-') + // Replace all forward slashes with dashes, `Text/Primary` -> `Text-Primary` + .replace(/\//g, '-') + // Replace `dash number number dash` with single dash, `Text-01-Primary` -> `Text-Primary` + .replace(/-\d\d-/g, '-') + // Replace all double dashes with single dashes, `Text--Primary` -> `Text-Primary` + .replace(/--+/g, '-') + // Make all characters lowercase, `Text-Primary` -> `text-primary` + .toLowerCase() + ); +} + +export function kebabCaseToPascalCase(name: string): string { + return ( + name + // Split the string at hyphens + .split('-') + // Capitalize the first letter of each segment + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase()) + // Join the segments back together + .join('') + ); +} + +export function kebabCaseToCamelCase(name: string): string { + return ( + name + // Split the string at hyphens + .split('-') + // Capitalize the first letter of each segment + .map((segment, index) => (index > 0 ? segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase() : segment)) + // Join the segments back together + .join('') + ); +} diff --git a/exporters/js/src/js/normalizers/plural.ts b/exporters/js/src/js/normalizers/plural.ts new file mode 100644 index 0000000000..51a925e9a4 --- /dev/null +++ b/exporters/js/src/js/normalizers/plural.ts @@ -0,0 +1,10 @@ +export function plural(name: string): string { + if (name === 'radius') { + return 'radii'; + } + if (name.slice(-1) === 's') { + return name; + } + + return `${name}s`; +} diff --git a/exporters/js/src/js/normalizers/singular.ts b/exporters/js/src/js/normalizers/singular.ts new file mode 100644 index 0000000000..a526dea263 --- /dev/null +++ b/exporters/js/src/js/normalizers/singular.ts @@ -0,0 +1,7 @@ +export function singular(name: string): string { + if (name.slice(-1) === 's') { + return name.replace(/.$/, ''); + } + + return name; +} diff --git a/exporters/js/src/js/printers/__tests__/types.test.ts b/exporters/js/src/js/printers/__tests__/types.test.ts new file mode 100644 index 0000000000..85aaaa2819 --- /dev/null +++ b/exporters/js/src/js/printers/__tests__/types.test.ts @@ -0,0 +1,27 @@ +import { printTypes } from '../types'; + +describe('printTypes', () => { + it.each([ + // types, name, expected + [ + { x: ['arnold'], y: ['rimmer'] }, + 'someName', + ` +export const x = { + arnold: xArnold, +}; + +export const y = { + rimmer: yRimmer, +}; + +export const someName = { + x, + y, +}; +`, + ], + ])('should print types', (types, name, expected) => { + expect(printTypes(types, name)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/printers/__tests__/unit.test.ts b/exporters/js/src/js/printers/__tests__/unit.test.ts new file mode 100644 index 0000000000..075cd43295 --- /dev/null +++ b/exporters/js/src/js/printers/__tests__/unit.test.ts @@ -0,0 +1,13 @@ +import { printUnit } from '../unit'; + +describe('printUnit', () => { + it.each([ + // value, unit, expected + [123, 'Pixels', '123px'], + [123, 'rem', '123rem'], + [-123, 'Pixels', '-123px'], + [-123, 'rem', '-123rem'], + ])('should print unit', (value, unit, expected) => { + expect(printUnit(value, unit)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/printers/types.ts b/exporters/js/src/js/printers/types.ts new file mode 100644 index 0000000000..c03419b94b --- /dev/null +++ b/exporters/js/src/js/printers/types.ts @@ -0,0 +1,25 @@ +import { kebabCaseToCamelCase, kebabCaseToPascalCase } from '../normalizers/names'; + +export function printTypes(types: Record, name: string) { + let result = ''; + const keys: string[] = []; + Object.entries(types).forEach(([key, value]) => { + const typeName = kebabCaseToCamelCase(key); + keys.push(typeName); + if (value.length > 0) { + result = `${result}\nexport const ${typeName} = { +${value + .map((val: string) => ` ${kebabCaseToCamelCase(val)}: ${kebabCaseToCamelCase(key)}${kebabCaseToPascalCase(val)},`) + .join('\n')} +};\n`; + } + }); + + if (keys.length > 0) { + result = `${result}\nexport const ${name} = { + ${keys.join(',\n ')}, +};\n`; + } + + return result; +} diff --git a/exporters/js/src/js/printers/unit.ts b/exporters/js/src/js/printers/unit.ts new file mode 100644 index 0000000000..1c295b80af --- /dev/null +++ b/exporters/js/src/js/printers/unit.ts @@ -0,0 +1,13 @@ +export function printUnit(value: number, unit: string): string { + let result = value.toString(); + if (+value !== 0) { + if (unit === 'Pixels') { + result += 'px'; + } + if (unit === 'rem') { + result += 'rem'; + } + } + + return result; +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token1.json b/exporters/js/src/js/sorters/__fixtures__/token1.json new file mode 100644 index 0000000000..81d697a2da --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token1.json @@ -0,0 +1,14 @@ +{ + "id": "test", + "name": "a", + "description": "test", + "tokenType": "test", + "origin": { + "source": "test", + "id": "test", + "name": "a" + }, + "value": { + "text": 10 + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token10.json b/exporters/js/src/js/sorters/__fixtures__/token10.json new file mode 100644 index 0000000000..cceea50535 --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token10.json @@ -0,0 +1,14 @@ +{ + "id": "test", + "name": "breakpoint-desktop", + "description": "test", + "tokenType": "test", + "origin": { + "source": "test", + "id": "test", + "name": "breakpoint-desktop" + }, + "value": { + "text": 10 + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token2.json b/exporters/js/src/js/sorters/__fixtures__/token2.json new file mode 100644 index 0000000000..2bbf013f06 --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token2.json @@ -0,0 +1,14 @@ +{ + "id": "test", + "name": "b", + "description": "test", + "tokenType": "test", + "origin": { + "source": "test", + "id": "test", + "name": "b" + }, + "value": { + "text": "20" + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token3.json b/exporters/js/src/js/sorters/__fixtures__/token3.json new file mode 100644 index 0000000000..61e9b63eb4 --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token3.json @@ -0,0 +1,14 @@ +{ + "id": "149fc92c-8586-11eb-a324-c7f25166e00c", + "name": "20", + "description": "20", + "tokenType": "Color", + "origin": { + "source": "Figma", + "id": "S:5642100401a4019c765b51f22f0f203de2cd1a02,", + "name": "Base/Pink/20" + }, + "value": { + "text": "30" + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token4.json b/exporters/js/src/js/sorters/__fixtures__/token4.json new file mode 100644 index 0000000000..60a6e66c60 --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token4.json @@ -0,0 +1,14 @@ +{ + "id": "149fc92b-8586-11eb-a324-c7f25166e00c", + "name": "10", + "description": "10", + "tokenType": "Color", + "origin": { + "source": "Figma", + "id": "S:494296a45a5072718577cc0faae3bd89e6c47207,", + "name": "Base/Pink/10" + }, + "value": { + "text": "40" + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token5.json b/exporters/js/src/js/sorters/__fixtures__/token5.json new file mode 100644 index 0000000000..c7d699b6ea --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token5.json @@ -0,0 +1,9 @@ +{ + "id": "149fc92c-8586-11eb-a324-c7f25166e00c", + "name": "display", + "description": "20", + "tokenType": "Color", + "value": { + "text": "50" + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token6.json b/exporters/js/src/js/sorters/__fixtures__/token6.json new file mode 100644 index 0000000000..cdfe5fcc12 --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token6.json @@ -0,0 +1,9 @@ +{ + "id": "149fc92c-8586-11eb-a324-c7f25166e00c", + "name": "color", + "description": "20", + "tokenType": "Color", + "value": { + "text": "60" + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token7.json b/exporters/js/src/js/sorters/__fixtures__/token7.json new file mode 100644 index 0000000000..cceea50535 --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token7.json @@ -0,0 +1,14 @@ +{ + "id": "test", + "name": "breakpoint-desktop", + "description": "test", + "tokenType": "test", + "origin": { + "source": "test", + "id": "test", + "name": "breakpoint-desktop" + }, + "value": { + "text": 10 + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token8.json b/exporters/js/src/js/sorters/__fixtures__/token8.json new file mode 100644 index 0000000000..68132903df --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token8.json @@ -0,0 +1,14 @@ +{ + "id": "test", + "name": "breakpoint-tablet", + "description": "test", + "tokenType": "test", + "origin": { + "source": "test", + "id": "test", + "name": "breakpoint-tablet" + }, + "value": { + "text": 10 + } +} diff --git a/exporters/js/src/js/sorters/__fixtures__/token9.json b/exporters/js/src/js/sorters/__fixtures__/token9.json new file mode 100644 index 0000000000..3e5e9adb91 --- /dev/null +++ b/exporters/js/src/js/sorters/__fixtures__/token9.json @@ -0,0 +1,14 @@ +{ + "id": "test", + "name": "grid-gutter-desktop", + "description": "test", + "tokenType": "test", + "origin": { + "source": "test", + "id": "test", + "name": "grid-gutter-desktop" + }, + "value": { + "text": 10 + } +} diff --git a/exporters/js/src/js/sorters/__tests__/breakpointSort.test.ts b/exporters/js/src/js/sorters/__tests__/breakpointSort.test.ts new file mode 100644 index 0000000000..160d00bbd5 --- /dev/null +++ b/exporters/js/src/js/sorters/__tests__/breakpointSort.test.ts @@ -0,0 +1,18 @@ +import { breakpointSort } from '../breakpointSort'; +import token7 from '../__fixtures__/token7.json'; +import token8 from '../__fixtures__/token8.json'; +import token9 from '../__fixtures__/token9.json'; +import token10 from '../__fixtures__/token10.json'; + +describe('breakpointSort', () => { + it.each([ + // > 0 sort a after b + // < 0 sort a before b + // === 0 keep original order of a and b + // tokenA, tokenB, expected + [token7, token8, 'mobile,tablet,desktop', false, 1], + [token9, token10, 'mobile,tablet,desktop', false, 1], + ])('should sort tokens based on locale', (tokenA, tokenB, breakpointsString, sortByValue, expected) => { + expect(breakpointSort(tokenA, tokenB, breakpointsString, sortByValue)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/sorters/__tests__/localeSort.test.ts b/exporters/js/src/js/sorters/__tests__/localeSort.test.ts new file mode 100644 index 0000000000..9f96154f78 --- /dev/null +++ b/exporters/js/src/js/sorters/__tests__/localeSort.test.ts @@ -0,0 +1,21 @@ +import { localeSort } from '../localeSort'; +import token1 from '../__fixtures__/token1.json'; +import token2 from '../__fixtures__/token2.json'; +import token3 from '../__fixtures__/token3.json'; +import token4 from '../__fixtures__/token4.json'; +import token5 from '../__fixtures__/token5.json'; +import token6 from '../__fixtures__/token6.json'; + +describe('localeSort', () => { + it.each([ + // > 0 sort a after b + // < 0 sort a before b + // === 0 keep original order of a and b + // tokenA, tokenB, expected + [token1, token2, -1], + [token3, token4, 1], + [token5, token6, 1], + ])('should sort tokens based on locale', (tokenA, tokenB, expected) => { + expect(localeSort(tokenA, tokenB)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/sorters/__tests__/valueSort.test.ts b/exporters/js/src/js/sorters/__tests__/valueSort.test.ts new file mode 100644 index 0000000000..57bf8cb827 --- /dev/null +++ b/exporters/js/src/js/sorters/__tests__/valueSort.test.ts @@ -0,0 +1,21 @@ +import { valueSort } from '../valueSort'; +import token1 from '../__fixtures__/token1.json'; +import token2 from '../__fixtures__/token2.json'; +import token3 from '../__fixtures__/token3.json'; +import token4 from '../__fixtures__/token4.json'; +import token5 from '../__fixtures__/token5.json'; +import token6 from '../__fixtures__/token6.json'; + +describe('valueSort', () => { + it.each([ + // > 0 sort a after b + // < 0 sort a before b + // === 0 keep original order of a and b + // tokenA, tokenB, expected + [token1, token2, -10], + [token4, token3, 10], + [token5, token6, -10], + ])('should sort tokens based on locale', (tokenA, tokenB, expected) => { + expect(valueSort(tokenA, tokenB)).toBe(expected); + }); +}); diff --git a/exporters/js/src/js/sorters/breakpointSort.ts b/exporters/js/src/js/sorters/breakpointSort.ts new file mode 100644 index 0000000000..94731512be --- /dev/null +++ b/exporters/js/src/js/sorters/breakpointSort.ts @@ -0,0 +1,31 @@ +import { Token } from '../index'; +import { localeSort } from './localeSort'; + +export function breakpointSort(a: Token, b: Token, breakpointsString: string, sortByValue: boolean) { + const breakpoints = breakpointsString.trim().split(','); + let aBreakpoint = ''; + let bBreakpoint = ''; + breakpoints.some((substring: string) => { + if ((a.origin ? a.origin.name : a.name).includes(substring)) { + aBreakpoint = substring; + } + + if ((b.origin ? b.origin.name : b.name).includes(substring)) { + bBreakpoint = substring; + } + + return false; + }); + + if (!!sortByValue && !aBreakpoint) { + return -1; + } + + let result = breakpoints.indexOf(aBreakpoint) - breakpoints.indexOf(bBreakpoint); + + if (result === 0) { + result = localeSort(a, b); + } + + return result; +} diff --git a/exporters/js/src/js/sorters/localeSort.ts b/exporters/js/src/js/sorters/localeSort.ts new file mode 100644 index 0000000000..0f3a0867d6 --- /dev/null +++ b/exporters/js/src/js/sorters/localeSort.ts @@ -0,0 +1,9 @@ +import { slugifyName } from '../normalizers/names'; +import type { Token } from '../index'; + +export function localeSort(a: Token, b: Token) { + const aCompare = slugifyName(a.origin ? a.origin.name : a.name); + const bCompare = slugifyName(b.origin ? b.origin.name : b.name); + + return aCompare.localeCompare(bCompare); +} diff --git a/exporters/js/src/js/sorters/valueSort.ts b/exporters/js/src/js/sorters/valueSort.ts new file mode 100644 index 0000000000..b4f3d94f79 --- /dev/null +++ b/exporters/js/src/js/sorters/valueSort.ts @@ -0,0 +1,7 @@ +import type { Token } from '../index'; + +export function valueSort(a: Token, b: Token) { + // Value is defined as `Record;` + // @ts-expect-error TS2571: Object is of type 'unknown'. + return +a.value.text - +b.value.text; +} diff --git a/exporters/js/src/measures.pr b/exporters/js/src/measures.pr new file mode 100644 index 0000000000..f36e343b88 --- /dev/null +++ b/exporters/js/src/measures.pr @@ -0,0 +1,4 @@ +// Generated Measures from Supernova. Do not edit manually. +{[ if (ds.tokensByType("Measure", ds.currentBrand().id)).count() > 0 ]} +{{ generateSimple("measures", ds.tokensByType("Measure", ds.currentBrand().id), [], true, false, '', 'space') }} +{[/]} diff --git a/exporters/js/src/other.pr b/exporters/js/src/other.pr new file mode 100644 index 0000000000..4ddd246b48 --- /dev/null +++ b/exporters/js/src/other.pr @@ -0,0 +1,5 @@ +// Generated Generic Tokens (Other) from Supernova. Do not edit manually. +{[ if (ds.tokensByType("GenericToken", ds.currentBrand().id)).count() > 0 ]} +{[ let breakpoints = exportConfiguration().breakpoints /]} +{{ generateSimple("other", ds.tokensByType("GenericToken", ds.currentBrand().id), ds.tokenGroupsOfType("GenericToken", ds.currentBrand().id), false, true, breakpoints) }} +{[/]} diff --git a/exporters/js/src/radii.pr b/exporters/js/src/radii.pr new file mode 100644 index 0000000000..9b6ee7bb29 --- /dev/null +++ b/exporters/js/src/radii.pr @@ -0,0 +1,4 @@ +// Generated Radii from Supernova. Do not edit manually. +{[ if (ds.tokensByType("Measure", ds.currentBrand().id)).count() > 0 ]} +{{ generateSimple("radii", ds.tokensByType("Measure", ds.currentBrand().id), [], true, false, '', 'radius') }} +{[/]} diff --git a/exporters/js/src/shadows.pr b/exporters/js/src/shadows.pr new file mode 100644 index 0000000000..6c59746f22 --- /dev/null +++ b/exporters/js/src/shadows.pr @@ -0,0 +1,4 @@ +// Generated Shadows from Supernova. Do not edit manually. +{[ if (ds.tokensByType("Shadow", ds.currentBrand().id)).count() > 0 ]} +{{ generateSimple("shadows", ds.tokensByType("Shadow", ds.currentBrand().id), ds.tokenGroupsOfType("Shadow", ds.currentBrand().id)) }} +{[/]} diff --git a/exporters/js/src/typography.pr b/exporters/js/src/typography.pr new file mode 100644 index 0000000000..a0c94db92f --- /dev/null +++ b/exporters/js/src/typography.pr @@ -0,0 +1,7 @@ +// Generated Typography from Supernova. Do not edit manually. +{[ let defaultFontSize = exportConfiguration().defaultFontSize /]} +{[ let fontFamilyFallback = exportConfiguration().fontFamilyFallback /]} +{[ let breakpoints = exportConfiguration().breakpoints /]} +{[ if (ds.tokensByType("Typography", ds.currentBrand().id)).count() > 0 ]} +{{ generateTypography(ds.tokensByType("Typography", ds.currentBrand().id), defaultFontSize, fontFamilyFallback, breakpoints) }} +{[/]} diff --git a/exporters/js/tsconfig.eslint.json b/exporters/js/tsconfig.eslint.json new file mode 100644 index 0000000000..dc13a26063 --- /dev/null +++ b/exporters/js/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["./", "./.eslintrc.js"] +} diff --git a/exporters/js/tsconfig.json b/exporters/js/tsconfig.json new file mode 100644 index 0000000000..47838b39ae --- /dev/null +++ b/exporters/js/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compileOnSave": true, + "compilerOptions": { + "baseUrl": ".", + "outDir": "./dist", + "sourceMap": true, + "declaration": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "module": "es2015", + "target": "es2015", + "noEmit": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "resolveJsonModule": true, + "noImplicitReturns": true, + "allowUnreachableCode": false, + "allowSyntheticDefaultImports": true, + "alwaysStrict": true, + "forceConsistentCasingInFileNames": true, + "noUnusedParameters": false, + "noUnusedLocals": true, + "strictFunctionTypes": true, + "noImplicitAny": true, + "esModuleInterop": true, + "typeRoots": ["../../node_modules/@types"], + "lib": ["es2015", "dom", "dom.iterable"], + "types": ["node", "jest", "@testing-library/jest-dom"] + }, + "include": ["./src/**/*"], + "exclude": ["./node_modules", "./dist/**/*"] +} diff --git a/exporters/js/vite.config.ts b/exporters/js/vite.config.ts new file mode 100644 index 0000000000..c458d27a99 --- /dev/null +++ b/exporters/js/vite.config.ts @@ -0,0 +1,20 @@ +import { resolve } from 'path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'src/js/index.ts'), + name: 'Functions', + fileName: 'functions', + formats: ['cjs'], + }, + rollupOptions: { + output: { + dir: resolve(__dirname, './generated'), + banner: + '/**\n * THIS FILE IS GENERATED USING `build` SCRIPT\n * DO NOT EDIT MANUALLY\n * SEE CONTRIBUTING.md\n*/', + }, + }, + }, +}); diff --git a/nx.json b/nx.json index 9720b9aa66..85783ca528 100644 --- a/nx.json +++ b/nx.json @@ -15,5 +15,13 @@ "@nrwl/js": { "analyzeSourceFiles": false } + }, + "targets": { + "build": { + "dependsOn": ["^build"] + }, + "test": { + "dependsOn": ["^build"] + } } } diff --git a/package.json b/package.json index ac4bae11c3..2dbc761016 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "packages:start": "lerna run start --parallel", "packages:test": "lerna run test", "packages:types": "lerna run types --no-sort", - "packages:build": "lerna run --ignore @lmc-eu/spirit-web-twig-demo build --no-sort", + "packages:build": "lerna run --ignore @lmc-eu/spirit-web-twig-demo build", "packages:publish": "lerna publish", "packages:diff": "lerna diff", "packages:changed": "lerna changed", diff --git a/packages/design-tokens/.gitignore b/packages/design-tokens/.gitignore new file mode 100644 index 0000000000..5ef5e94def --- /dev/null +++ b/packages/design-tokens/.gitignore @@ -0,0 +1,5 @@ +esm +scss +cjs +umd +types diff --git a/packages/design-tokens/README.md b/packages/design-tokens/README.md index 09688158cc..1dbd9eab9d 100644 --- a/packages/design-tokens/README.md +++ b/packages/design-tokens/README.md @@ -84,6 +84,18 @@ incompatible with `@tokens` API that makes rebranding possible. +### In JavaScript + +Additionally the design tokens are also provided as a JavaScript object. + +```js +import * as SpiritDesignTokens from '@lmc-eu/spirit-design-tokens'; + +const desktopBreakpoint = SpiritDesignTokens.breakpoints.desktop; +``` + +The structure is the same as in the SASS. + ## `@tokens` API `@tokens` API enables quick and easy rebranding of Spirit Sass styles by diff --git a/packages/design-tokens/package.json b/packages/design-tokens/package.json index 5cd57e4591..6e5ad79ed0 100644 --- a/packages/design-tokens/package.json +++ b/packages/design-tokens/package.json @@ -4,23 +4,48 @@ "description": "Design tokens for Spirit Design System", "license": "MIT", "publishConfig": { - "access": "public", - "directory": "dist" + "access": "public" }, + "files": [ + "scss", + "esm", + "cjs", + "types", + "CHANGELOG.md" + ], "repository": { "type": "git", "url": "https://github.com/lmc-eu/spirit-design-system.git", "directory": "packages/design-tokens" }, + "module": "esm/index.js", + "main": "cjs/index.js", + "types": "types/index.d.ts", + "exports": { + ".": { + "import": "./esm/index.js", + "require": "./cjs/index.js" + } + }, "scripts": { - "build": "shx rm -rf dist && shx mkdir -p dist/scss && shx cp -r package.json README.md dist/ && shx cp -r src/scss/generated/* src/scss/@tokens.scss dist/scss/", + "prebuild": "yarn clean", + "build:js": "vite build", + "build:scss": "shx mkdir -p scss && shx cp -r src/scss/generated/* src/scss/@tokens.scss scss/", + "build": "npm-run-all -s build:scss build:js", + "clean": "rimraf esm cjs umd scss types", "lint": "stylelint --config ../../.stylelintrc.js ./src/**/*.scss", - "test": "yarn lint" + "test": "yarn lint", + "types": "tsc" }, "devDependencies": { "@lmc-eu/stylelint-config": "7.0.1", - "shx": "0.3.4", "stylelint": "15.11.0", - "stylelint-order": "6.0.3" + "npm-run-all": "^4.1.5", + "rimraf": "^5.0.5", + "shx": "0.3.4", + "stylelint-order": "6.0.3", + "typescript": "^5.2.2", + "vite": "^4.5.0", + "vite-plugin-dts": "^3.6.0" } } diff --git a/packages/design-tokens/src/js/generated/borders.ts b/packages/design-tokens/src/js/generated/borders.ts new file mode 100644 index 0000000000..4bc1e06ab3 --- /dev/null +++ b/packages/design-tokens/src/js/generated/borders.ts @@ -0,0 +1,24 @@ +export const borderStyle0 = 'none'; +export const borderStyle100 = 'solid'; +export const borderStyle200 = 'dashed'; +export const borderStyle = { + 0: borderStyle0, + 100: borderStyle100, + 200: borderStyle200, +}; + +export const borderWidth0 = 0; +export const borderWidth100 = '1px'; +export const borderWidth200 = '2px'; +export const borderWidth = { + 0: borderWidth0, + 100: borderWidth100, + 200: borderWidth200, +}; + +export const borders = { + borderStyle, + borderWidth, + style: borderStyle, + width: borderWidth, +}; diff --git a/packages/design-tokens/src/js/generated/colors.ts b/packages/design-tokens/src/js/generated/colors.ts new file mode 100644 index 0000000000..3e84a3fad2 --- /dev/null +++ b/packages/design-tokens/src/js/generated/colors.ts @@ -0,0 +1,301 @@ +export const actionInvertedActive = '#d4d4d4'; +export const actionInvertedDefault = '#e9e9e9'; +export const actionInvertedDisabled = '#c4c4c4'; +export const actionInvertedHover = '#dbdbdb'; +export const actionInverted = { + active: actionInvertedActive, + default: actionInvertedDefault, + disabled: actionInvertedDisabled, + hover: actionInvertedHover, +}; + +export const actionLinkInvertedActive = '#d4d4d4'; +export const actionLinkInvertedDefault = '#e9e9e9'; +export const actionLinkInvertedDisabled = '#c4c4c4'; +export const actionLinkInvertedHover = '#dbdbdb'; +export const actionLinkInvertedVisited = '#a7bcc2'; +export const actionLinkInverted = { + active: actionLinkInvertedActive, + default: actionLinkInvertedDefault, + disabled: actionLinkInvertedDisabled, + hover: actionLinkInvertedHover, + visited: actionLinkInvertedVisited, +}; + +export const actionLinkPrimaryActive = '#0b3a46'; +export const actionLinkPrimaryDefault = '#29616f'; +export const actionLinkPrimaryDisabled = '#c4c4c4'; +export const actionLinkPrimaryHover = '#1b5260'; +export const actionLinkPrimaryVisited = '#a7bcc2'; +export const actionLinkPrimary = { + active: actionLinkPrimaryActive, + default: actionLinkPrimaryDefault, + disabled: actionLinkPrimaryDisabled, + hover: actionLinkPrimaryHover, + visited: actionLinkPrimaryVisited, +}; + +export const actionLinkSecondaryActive = '#6e7b80'; +export const actionLinkSecondaryDefault = '#90a2a7'; +export const actionLinkSecondaryDisabled = '#c4c4c4'; +export const actionLinkSecondaryHover = '#849499'; +export const actionLinkSecondaryVisited = '#a7bcc2'; +export const actionLinkSecondary = { + active: actionLinkSecondaryActive, + default: actionLinkSecondaryDefault, + disabled: actionLinkSecondaryDisabled, + hover: actionLinkSecondaryHover, + visited: actionLinkSecondaryVisited, +}; + +export const actionLink = { + primary: actionLinkPrimary, + secondary: actionLinkSecondary, + inverted: actionLinkInverted, +}; + +export const actionPrimaryActive = '#0b3a46'; +export const actionPrimaryDefault = '#29616f'; +export const actionPrimaryDisabled = '#c4c4c4'; +export const actionPrimaryHover = '#1b5260'; +export const actionPrimary = { + active: actionPrimaryActive, + default: actionPrimaryDefault, + disabled: actionPrimaryDisabled, + hover: actionPrimaryHover, +}; + +export const actionSecondaryActive = '#c4c4c4'; +export const actionSecondaryDefault = '#a0a0a0'; +export const actionSecondaryDisabled = '#c4c4c4'; +export const actionSecondaryHover = '#737373'; +export const actionSecondary = { + active: actionSecondaryActive, + default: actionSecondaryDefault, + disabled: actionSecondaryDisabled, + hover: actionSecondaryHover, +}; + +export const actionSelectedActive = '#0b3a46'; +export const actionSelectedDefault = '#29616f'; +export const actionSelectedDisabled = '#c4c4c4'; +export const actionSelectedHover = '#1b5260'; +export const actionSelected = { + active: actionSelectedActive, + default: actionSelectedDefault, + disabled: actionSelectedDisabled, + hover: actionSelectedHover, +}; + +export const actionTertiaryActive = '#d4d4d4'; +export const actionTertiaryDefault = '#e9e9e9'; +export const actionTertiaryDisabled = '#c4c4c4'; +export const actionTertiaryHover = '#dbdbdb'; +export const actionTertiary = { + active: actionTertiaryActive, + default: actionTertiaryDefault, + disabled: actionTertiaryDisabled, + hover: actionTertiaryHover, +}; + +export const actionUnselectedActive = '#091417'; +export const actionUnselectedDefault = '#132930'; +export const actionUnselectedDisabled = '#c4c4c4'; +export const actionUnselectedHover = '#0b1c21'; +export const actionUnselected = { + active: actionUnselectedActive, + default: actionUnselectedDefault, + disabled: actionUnselectedDisabled, + hover: actionUnselectedHover, +}; + +export const action = { + inverted: actionInverted, + link: { + inverted: actionLinkInverted, + primary: actionLinkPrimary, + secondary: actionLinkSecondary, + }, + primary: actionPrimary, + secondary: actionSecondary, + selected: actionSelected, + tertiary: actionTertiary, + unselected: actionUnselected, +}; + +export const backgroundColorBackdrop = '#0b1c2199'; +export const backgroundColorBasic = '#fff'; +export const backgroundColorCover = '#f0f4f5'; +export const backgroundColor = { + backdrop: backgroundColorBackdrop, + basic: backgroundColorBasic, + cover: backgroundColorCover, +}; + +export const backgroundInteractiveActive = '#0f343c4d'; +export const backgroundInteractiveDefault = '#ffffff00'; +export const backgroundInteractiveHover = '#0f383e33'; +export const backgroundInteractive = { + active: backgroundInteractiveActive, + default: backgroundInteractiveDefault, + hover: backgroundInteractiveHover, +}; + +export const backgroundInteractiveInvertedActive = '#0f3d42cc'; +export const backgroundInteractiveInvertedDefault = '#ffffff00'; +export const backgroundInteractiveInvertedHover = '#0f343c66'; +export const backgroundInteractiveInverted = { + active: backgroundInteractiveInvertedActive, + default: backgroundInteractiveInvertedDefault, + hover: backgroundInteractiveInvertedHover, +}; + +export const backgroundInverted = '#132930'; + +export const background = { + ...backgroundColor, + interactive: { + ...backgroundInteractive, + inverted: backgroundInteractiveInverted, + }, + inverted: backgroundInverted, +}; + +export const borderPrimaryActive = '#d4d4d4'; +export const borderPrimaryDefault = '#e9e9e9'; +export const borderPrimaryDisabled = '#f4f4f4'; +export const borderPrimaryHover = '#dbdbdb'; +export const borderPrimarySelected = '#29616f'; +export const borderPrimary = { + active: borderPrimaryActive, + default: borderPrimaryDefault, + disabled: borderPrimaryDisabled, + hover: borderPrimaryHover, + selected: borderPrimarySelected, +}; + +export const borderSecondaryDefault = '#e9e9e9'; + +export const border = { + primary: borderPrimary, + secondary: { + default: borderSecondaryDefault, + }, +}; + +export const brandPrimary = '#0b1c21'; +export const brandSecondary = '#30588c'; +export const brand = { + primary: brandPrimary, + secondary: brandSecondary, +}; + +export const emotionDangerActive = '#6f2535'; +export const emotionDangerBackground = '#fbeef1'; +export const emotionDangerDefault = '#ba3e5a'; +export const emotionDangerDisabled = '#c4c4c4'; +export const emotionDangerHover = '#953248'; +export const emotionDanger = { + active: emotionDangerActive, + background: emotionDangerBackground, + default: emotionDangerDefault, + disabled: emotionDangerDisabled, + hover: emotionDangerHover, +}; + +export const emotionInformativeActive = '#26456e'; +export const emotionInformativeBackground = '#e8eff7'; +export const emotionInformativeDefault = '#3b6bab'; +export const emotionInformativeDisabled = '#c4c4c4'; +export const emotionInformativeHover = '#30588c'; +export const emotionInformative = { + active: emotionInformativeActive, + background: emotionInformativeBackground, + default: emotionInformativeDefault, + disabled: emotionInformativeDisabled, + hover: emotionInformativeHover, +}; + +export const emotionSuccessActive = '#33420a'; +export const emotionSuccessBackground = '#f6fbe9'; +export const emotionSuccessDefault = '#607c13'; +export const emotionSuccessDisabled = '#c4c4c4'; +export const emotionSuccessHover = '#485d0e'; +export const emotionSuccess = { + active: emotionSuccessActive, + background: emotionSuccessBackground, + default: emotionSuccessDefault, + disabled: emotionSuccessDisabled, + hover: emotionSuccessHover, +}; + +export const emotionWarningActive = '#423400'; +export const emotionWarningBackground = '#f8f2e4'; +export const emotionWarningDefault = '#a98300'; +export const emotionWarningDisabled = '#c4c4c4'; +export const emotionWarningHover = '#755b00'; +export const emotionWarning = { + active: emotionWarningActive, + background: emotionWarningBackground, + default: emotionWarningDefault, + disabled: emotionWarningDisabled, + hover: emotionWarningHover, +}; + +export const emotion = { + danger: emotionDanger, + informative: emotionInformative, + success: emotionSuccess, + warning: emotionWarning, +}; + +export const focusDefault = '#4666ae'; + +export const textPrimaryDefault = '#132930'; +export const textPrimaryDisabled = '#c4c4c4'; +export const textPrimary = { + default: textPrimaryDefault, + disabled: textPrimaryDisabled, +}; + +export const textPrimaryInvertedDefault = '#fff'; +export const textPrimaryInvertedDisabled = '#737373'; +export const textPrimaryInverted = { + default: textPrimaryInvertedDefault, + disabled: textPrimaryInvertedDisabled, +}; + +export const textSecondaryDefault = '#90a2a7'; +export const textSecondaryDisabled = '#c4c4c4'; +export const textSecondary = { + default: textSecondaryDefault, + disabled: textSecondaryDisabled, +}; + +export const textSecondaryInvertedDefault = '#e9e9e9'; +export const textSecondaryInvertedDisabled = '#737373'; +export const textSecondaryInverted = { + default: textSecondaryInvertedDefault, + disabled: textSecondaryInvertedDisabled, +}; + +export const text = { + primary: textPrimary, + secodary: textSecondary, + inverted: { + primary: textPrimaryInverted, + secondary: textSecondaryInverted, + }, +}; + +export const colors = { + action, + background, + border, + brand, + emotion, + focus: { + default: focusDefault, + }, + text, +}; diff --git a/packages/design-tokens/src/js/generated/gradients.ts b/packages/design-tokens/src/js/generated/gradients.ts new file mode 100644 index 0000000000..f7470ef34a --- /dev/null +++ b/packages/design-tokens/src/js/generated/gradients.ts @@ -0,0 +1,5 @@ +export const gradientBackgroundBasicOverlay = 'linear-gradient(var(--angle, 90deg), #fff 0%, #ffffff00 100%)'; + +export const gradients = { + backgroundBasicOverlay: gradientBackgroundBasicOverlay, +}; diff --git a/packages/design-tokens/src/js/generated/index.ts b/packages/design-tokens/src/js/generated/index.ts new file mode 100644 index 0000000000..c3bd7c48cc --- /dev/null +++ b/packages/design-tokens/src/js/generated/index.ts @@ -0,0 +1,8 @@ +export * from './borders'; +export * from './colors'; +export * from './gradients'; +export * from './measures'; +export * from './other'; +export * from './radii'; +export * from './shadows'; +export * from './typography'; diff --git a/packages/design-tokens/src/js/generated/measures.ts b/packages/design-tokens/src/js/generated/measures.ts new file mode 100644 index 0000000000..e4d9e2d431 --- /dev/null +++ b/packages/design-tokens/src/js/generated/measures.ts @@ -0,0 +1,33 @@ +export const space0 = 0; +export const space100 = '1px'; +export const space200 = '2px'; +export const space300 = '4px'; +export const space400 = '8px'; +export const space500 = '12px'; +export const space600 = '16px'; +export const space700 = '24px'; +export const space800 = '32px'; +export const space900 = '40px'; +export const space1000 = '48px'; +export const space1100 = '64px'; +export const space1200 = '80px'; + +export const space = { + 0: space0, + 100: space100, + 200: space200, + 300: space300, + 400: space400, + 500: space500, + 600: space600, + 700: space700, + 800: space800, + 900: space900, + 1000: space1000, + 1100: space1100, + 1200: space1200, +}; + +export const spaces = { + space, +}; diff --git a/packages/design-tokens/src/js/generated/other.ts b/packages/design-tokens/src/js/generated/other.ts new file mode 100644 index 0000000000..b9bb992016 --- /dev/null +++ b/packages/design-tokens/src/js/generated/other.ts @@ -0,0 +1,47 @@ +export const containerMaxWidth = '1280px'; + +export const gridColumns = 12; + +export const breakpointMobile = 0; +export const containerPaddingMobile = '16px'; +export const gridGutterMobile = '16px'; + +export const breakpointTablet = '768px'; +export const containerPaddingTablet = '32px'; +export const gridGutterTablet = '32px'; + +export const breakpointDesktop = '1280px'; +export const containerPaddingDesktop = '32px'; +export const gridGutterDesktop = '32px'; + +export const containers = { + maxWidth: containerMaxWidth, +}; + +export const grids = { + columns: gridColumns, +}; + +export const breakpoints = { + mobile: breakpointMobile, + tablet: breakpointTablet, + desktop: breakpointDesktop, +}; + +export const containerPaddings = { + mobile: containerPaddingMobile, + tablet: containerPaddingTablet, + desktop: containerPaddingDesktop, +}; + +export const gridGutters = { + mobile: gridGutterMobile, + tablet: gridGutterTablet, + desktop: gridGutterDesktop, +}; + +export const others = { + breakpoints, + containerPaddings, + gridGutters, +}; diff --git a/packages/design-tokens/src/js/generated/radii.ts b/packages/design-tokens/src/js/generated/radii.ts new file mode 100644 index 0000000000..fe313f0c7b --- /dev/null +++ b/packages/design-tokens/src/js/generated/radii.ts @@ -0,0 +1,15 @@ +export const radius0 = 0; +export const radius100 = '4px'; +export const radius200 = '8px'; +export const radius300 = '12px'; + +export const radius = { + 0: radius0, + 100: radius100, + 200: radius200, + 300: radius300, +}; + +export const radii = { + radius, +}; diff --git a/packages/design-tokens/src/js/generated/shadows.ts b/packages/design-tokens/src/js/generated/shadows.ts new file mode 100644 index 0000000000..cc986149a8 --- /dev/null +++ b/packages/design-tokens/src/js/generated/shadows.ts @@ -0,0 +1,18 @@ +export const focus = '0 0 0 2px #4666ae99'; + +export const shadow100 = '0 2px 8px 0 #00000026'; +export const shadow200 = '0 4px 12px 0 #00000033'; +export const shadow300 = '0 8px 24px 0 #00000040'; +export const shadow400 = '0 12px 32px 0 #00000040'; + +export const shadow = { + 100: shadow100, + 200: shadow200, + 300: shadow300, + 400: shadow400, +}; + +export const shadows = { + focus, + shadow, +}; diff --git a/packages/design-tokens/src/js/generated/typography.ts b/packages/design-tokens/src/js/generated/typography.ts new file mode 100644 index 0000000000..8ba16329de --- /dev/null +++ b/packages/design-tokens/src/js/generated/typography.ts @@ -0,0 +1,319 @@ +export const bodyLargeTextBold = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '1.125rem', + fontStyle: 'normal', + fontWeight: 600, + lineHeight: 1.55, + }, +}; + +export const bodyLargeTextItalic = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '1.125rem', + fontStyle: 'italic', + fontWeight: 400, + lineHeight: 1.55, + }, +}; + +export const bodyLargeTextRegular = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '1.125rem', + fontStyle: 'normal', + fontWeight: 400, + lineHeight: 1.55, + }, +}; + +export const bodyLarge = { + bold: bodyLargeTextBold, + italic: bodyLargeTextItalic, + regular: bodyLargeTextRegular, +}; + +export const bodyMediumTextBold = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '1rem', + fontStyle: 'normal', + fontWeight: 600, + lineHeight: 1.5, + }, +}; + +export const bodyMediumTextItalic = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '1rem', + fontStyle: 'italic', + fontWeight: 400, + lineHeight: 1.5, + }, +}; + +export const bodyMediumTextRegular = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '1rem', + fontStyle: 'normal', + fontWeight: 400, + lineHeight: 1.5, + }, +}; + +export const bodyMedium = { + bold: bodyMediumTextBold, + italic: bodyMediumTextItalic, + regular: bodyMediumTextRegular, +}; + +export const bodySmallTextBold = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '0.875rem', + fontStyle: 'normal', + fontWeight: 600, + lineHeight: 1.42, + }, +}; + +export const bodySmallTextItalic = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '0.875rem', + fontStyle: 'italic', + fontWeight: 400, + lineHeight: 1.42, + }, +}; + +export const bodySmallTextRegular = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '0.875rem', + fontStyle: 'normal', + fontWeight: 400, + lineHeight: 1.42, + }, +}; + +export const bodySmall = { + bold: bodySmallTextBold, + italic: bodySmallTextItalic, + regular: bodySmallTextRegular, +}; + +export const bodyXLargeTextBold = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '1.25rem', + fontStyle: 'normal', + fontWeight: 700, + lineHeight: 1.6, + }, +}; + +export const bodyXLargeTextItalic = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '1.25rem', + fontStyle: 'italic', + fontWeight: 400, + lineHeight: 1.6, + }, +}; + +export const bodyXLargeTextRegular = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '1.25rem', + fontStyle: 'normal', + fontWeight: 400, + lineHeight: 1.6, + }, +}; + +export const bodyXLarge = { + bold: bodyXLargeTextBold, + italic: bodyXLargeTextItalic, + regular: bodyXLargeTextRegular, +}; + +export const bodyXSmallTextBold = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '0.75rem', + fontStyle: 'normal', + fontWeight: 600, + lineHeight: 1.32, + }, +}; + +export const bodyXSmallTextItalic = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '0.75rem', + fontStyle: 'italic', + fontWeight: 400, + lineHeight: 1.32, + }, +}; + +export const bodyXSmallTextRegular = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '0.75rem', + fontStyle: 'normal', + fontWeight: 400, + lineHeight: 1.32, + }, +}; + +export const bodyXSmall = { + bold: bodyXSmallTextBold, + italic: bodyXSmallTextItalic, + regular: bodyXSmallTextRegular, +}; + +export const body = { + large: bodyLarge, + medium: bodyMedium, + small: bodySmall, + xlarge: bodyXLarge, + xsmall: bodyXSmall, +}; + +export const headingLargeText = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '2rem', + fontStyle: 'normal', + fontWeight: 700, + lineHeight: 1.2, + }, + tablet: { + fontFamily: "'Inter', sans-serif", + fontSize: '3rem', + fontStyle: 'normal', + fontWeight: 700, + lineHeight: 1.2, + }, + desktop: { + fontFamily: "'Inter', sans-serif", + fontSize: '3rem', + fontStyle: 'normal', + fontWeight: 700, + lineHeight: 1.2, + }, +}; + +export const headingMediumText = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '1.25rem', + fontStyle: 'normal', + fontWeight: 700, + lineHeight: 1.2, + }, + tablet: { + fontFamily: "'Inter', sans-serif", + fontSize: '2rem', + fontStyle: 'normal', + fontWeight: 700, + lineHeight: 1.2, + }, + desktop: { + fontFamily: "'Inter', sans-serif", + fontSize: '2rem', + fontStyle: 'normal', + fontWeight: 700, + lineHeight: 1.2, + }, +}; + +export const headingSmallText = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '1.125rem', + fontStyle: 'normal', + fontWeight: 700, + lineHeight: 1.2, + }, + tablet: { + fontFamily: "'Inter', sans-serif", + fontSize: '1.5rem', + fontStyle: 'normal', + fontWeight: 700, + lineHeight: 1.2, + }, + desktop: { + fontFamily: "'Inter', sans-serif", + fontSize: '1.5rem', + fontStyle: 'normal', + fontWeight: 700, + lineHeight: 1.2, + }, +}; + +export const headingXLargeText = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '3rem', + fontStyle: 'normal', + fontWeight: 700, + lineHeight: 1.2, + }, + tablet: { + fontFamily: "'Inter', sans-serif", + fontSize: '4rem', + fontStyle: 'normal', + fontWeight: 700, + lineHeight: 1.2, + }, + desktop: { + fontFamily: "'Inter', sans-serif", + fontSize: '4rem', + fontStyle: 'normal', + fontWeight: 700, + lineHeight: 1.2, + }, +}; + +export const headingXSmallText = { + mobile: { + fontFamily: "'Inter', sans-serif", + fontSize: '1rem', + fontStyle: 'normal', + fontWeight: 700, + lineHeight: 1.2, + }, + tablet: { + fontFamily: "'Inter', sans-serif", + fontSize: '1.25rem', + fontStyle: 'normal', + fontWeight: 700, + lineHeight: 1.2, + }, + desktop: { + fontFamily: "'Inter', sans-serif", + fontSize: '1.25rem', + fontStyle: 'normal', + fontWeight: 700, + lineHeight: 1.2, + }, +}; +export const heading = { + large: headingLargeText, + medium: headingMediumText, + small: headingSmallText, + xlarge: headingXLargeText, + xsmall: headingXSmallText, +}; + +export const styles = { + body, + heading, +}; diff --git a/packages/design-tokens/src/js/index.ts b/packages/design-tokens/src/js/index.ts new file mode 100644 index 0000000000..69e4e4eb8b --- /dev/null +++ b/packages/design-tokens/src/js/index.ts @@ -0,0 +1 @@ +export * from './generated'; diff --git a/packages/design-tokens/tsconfig.json b/packages/design-tokens/tsconfig.json new file mode 100644 index 0000000000..45978b6173 --- /dev/null +++ b/packages/design-tokens/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "types": ["node"], + "noEmit": true + }, + "include": ["src/**/*"] +} diff --git a/packages/design-tokens/vite.config.ts b/packages/design-tokens/vite.config.ts new file mode 100644 index 0000000000..125e4ccbc0 --- /dev/null +++ b/packages/design-tokens/vite.config.ts @@ -0,0 +1,38 @@ +import { resolve } from 'path'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +export default defineConfig({ + plugins: [ + dts({ + insertTypesEntry: true, + rollupTypes: false, + outDir: 'types', + }), + ], + build: { + outDir: resolve(__dirname, './'), + lib: { + entry: resolve(__dirname, 'src/js/index.ts'), + name: 'spirit-design-tokens', + fileName: 'index', + }, + rollupOptions: { + output: [ + { + format: 'cjs', + entryFileNames: '[format]/[name].js', + }, + { + format: 'es', + entryFileNames: 'esm/[name].js', + }, + { + name: 'spirit-design-tokens', + format: 'umd', + entryFileNames: '[format]/[name].js', + }, + ], + }, + }, +}); diff --git a/packages/web/package.json b/packages/web/package.json index 1860db9d2d..57b64b58e8 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -17,22 +17,23 @@ "types": "js/index.d.ts", "scripts": { "icons:build": "shx mkdir -p public/icons && cd ../icons && yarn build && shx cp -r dist/. ../web/public/icons", + "design-tokens:build": "cd ../design-tokens && yarn build", "prestart": "yarn icons:build", "start": "vite --host", - "preexamples:build": "yarn icons:build", + "preexamples:build": "npm-run-all icons:build design-tokens:build", "examples:build": "vite build", "examples:build:gh": "yarn examples:build --base=/spirit-design-system/", "examples:preview": "vite preview", "prebuild": "shx rm -rf dist && shx mkdir -p dist/scss && shx cp package.json README.md dist/ && shx cp -r src/scss dist/", - "build": "npm-run-all --serial css js", + "build": "npm-run-all --serial design-tokens:build css js", "precss": "yarn css:lint", "css": "yarn css:compile && yarn css:prefix && yarn css:minify", "css:lint": "stylelint --config ../../.stylelintrc.js \"src/**/*.scss\" --cache --cache-location .cache/.stylelintcache", "css:lint:fix": "stylelint --config ../../.stylelintrc.js \"src/**/*.scss\" --fix", - "css:compile:components": "sass --load-path=../design-tokens/dist/scss src/scss/components/index.scss dist/css/components.css", - "css:compile:foundation": "sass --load-path=../design-tokens/dist/scss --load-path=../../node_modules src/scss/foundation/index.scss dist/css/foundation.css", - "css:compile:helpers": "sass --load-path=../design-tokens/dist/scss src/scss/helpers/index.scss dist/css/helpers.css", - "css:compile:utilities": "sass --load-path=../design-tokens/dist/scss src/scss/utilities/index.scss dist/css/utilities.css", + "css:compile:components": "sass --load-path=../design-tokens/scss src/scss/components/index.scss dist/css/components.css", + "css:compile:foundation": "sass --load-path=../design-tokens/scss --load-path=../../node_modules src/scss/foundation/index.scss dist/css/foundation.css", + "css:compile:helpers": "sass --load-path=../design-tokens/scss src/scss/helpers/index.scss dist/css/helpers.css", + "css:compile:utilities": "sass --load-path=../design-tokens/scss src/scss/utilities/index.scss dist/css/utilities.css", "css:compile": "yarn css:compile:components && yarn css:compile:foundation && yarn css:compile:helpers && yarn css:compile:utilities", "css:prefix": "postcss --config ./config/postcss.config.js --replace \"dist/css/*.css\" \"!dist/css/*.min.css\"", "css:minify": "cleancss --format breaksWith=lf --source-map-inline-sources --batch --batch-suffix \".min\" \"dist/css/*.css\" \"!dist/css/*.min.css\"", @@ -53,7 +54,7 @@ }, "dependencies": { "@csstools/normalize.css": "^12.0.0", - "@lmc-eu/spirit-design-tokens": "^0.25.5", + "@lmc-eu/spirit-design-tokens": "^1.0.3", "@lmc-eu/spirit-icons": "^0.10.2" }, "devDependencies": { diff --git a/packages/web/scripts/prepareDist.js b/packages/web/scripts/prepareDist.js index bcfb7430b0..12fbd48d07 100644 --- a/packages/web/scripts/prepareDist.js +++ b/packages/web/scripts/prepareDist.js @@ -1,5 +1,5 @@ +import { existsSync, readdirSync } from 'fs'; import { resolve } from 'path'; -import { readdirSync } from 'fs'; const getDirs = (source) => readdirSync(source, { withFileTypes: true }) @@ -8,9 +8,12 @@ const getDirs = (source) => export const getNestedDirs = (baseDir, mainFile) => getDirs(resolve(__dirname, `../${baseDir}`)).reduce( - (accumulator, dirName) => ({ - ...accumulator, - [dirName]: resolve(__dirname, `../${baseDir}/${dirName}/${mainFile}`), - }), + (accumulator, dirName) => + existsSync(resolve(__dirname, `../${baseDir}/${dirName}/${mainFile}`)) + ? { + ...accumulator, + [dirName]: resolve(__dirname, `../${baseDir}/${dirName}/${mainFile}`), + } + : accumulator, {}, ); diff --git a/packages/web/src/js/AutoResize.ts b/packages/web/src/js/AutoResize.ts index 75c42c7b4b..05a1aaffb2 100644 --- a/packages/web/src/js/AutoResize.ts +++ b/packages/web/src/js/AutoResize.ts @@ -1,6 +1,6 @@ import BaseComponent from './BaseComponent'; import { EventHandler, SelectorEngine } from './dom'; -import { enableToggleAutoloader } from './utils'; +import { SpiritConfig, enableToggleAutoloader } from './utils'; const NAME = 'autoResize'; const RESIZE_EVENT = 'resize'; @@ -17,8 +17,8 @@ class AutoResize extends BaseComponent { return `${this.NAME}`; } - constructor(element: HTMLElement) { - super(element); + constructor(element: SpiritElement, config?: SpiritConfig) { + super(element, config); this.input = SelectorEngine.findOne('textarea', this.element) as HTMLTextAreaElement; this.init(); diff --git a/packages/web/src/js/BaseComponent.ts b/packages/web/src/js/BaseComponent.ts index 2e1662750b..13c1988939 100644 --- a/packages/web/src/js/BaseComponent.ts +++ b/packages/web/src/js/BaseComponent.ts @@ -1,17 +1,20 @@ -import { getElement } from './utils/index'; import InstanceMap from './dom/InstanceMap'; +import { Config, SpiritConfig, getElement } from './utils'; interface IBaseComponent extends FunctionConstructor { INSTANCE_KEY: string; } -class BaseComponent { +class BaseComponent extends Config { element: SpiritElement; + config: unknown; NAME: string | null; - constructor(element: SpiritElement | string) { + constructor(element: SpiritElement | string, config?: SpiritConfig) { + super(); this.element = getElement(element); this.NAME = ''; + this.config = this.getConfig(config); InstanceMap.set(this.element, (this.constructor as IBaseComponent).INSTANCE_KEY, this); } @@ -27,6 +30,13 @@ class BaseComponent { } } + getConfig(config?: SpiritConfig) { + const mergedConfig = this.mergeConfigObj(config, this.element); + this.typeCheckConfig(mergedConfig); + + return mergedConfig; + } + static get NAME() { return ''; } @@ -35,12 +45,12 @@ class BaseComponent { return InstanceMap.get(getElement(element), this.INSTANCE_KEY); } - static getOrCreateInstance(element: SpiritElement) { - return this.getInstance(element) || this.createInstance(element); + static getOrCreateInstance(element: SpiritElement, config = {}) { + return this.getInstance(element) || this.createInstance(element, config); } - static createInstance(element: SpiritElement) { - return new this(element); + static createInstance(element: SpiritElement, config: SpiritConfig) { + return new this(element, typeof config === 'object' ? config : null); } static get INSTANCE_KEY() { diff --git a/packages/web/src/js/Collapse.ts b/packages/web/src/js/Collapse.ts index 3dd87f44c9..dc27395e36 100644 --- a/packages/web/src/js/Collapse.ts +++ b/packages/web/src/js/Collapse.ts @@ -1,16 +1,15 @@ import BaseComponent from './BaseComponent'; -import { enableToggleAutoloader } from './utils/ComponentFunctions'; -import { executeAfterTransition } from './utils'; -import SelectorEngine from './dom/SelectorEngine'; -import EventHandler from './dom/EventHandler'; import { - ARIA_EXPANDED_ATTRIBUTE, ARIA_CONTROLS_ATTRIBUTE, - NAME_DATA_TOGGLE, - NAME_DATA_TARGET, + ARIA_EXPANDED_ATTRIBUTE, CLASSNAME_OPEN, CLASSNAME_TRANSITION, + NAME_DATA_TARGET, + NAME_DATA_TOGGLE, } from './constants'; +import EventHandler from './dom/EventHandler'; +import SelectorEngine from './dom/SelectorEngine'; +import { SpiritConfig, enableToggleAutoloader, executeAfterTransition } from './utils'; const NAME = 'collapse'; const DATA_KEY = 'collapse'; @@ -36,8 +35,8 @@ class Collapse extends BaseComponent { meta: CollapseMeta; state: CollapseState; - constructor(element: HTMLElement) { - super(element); + constructor(element: SpiritElement, config?: SpiritConfig) { + super(element, config); this.target = this.element.dataset.spiritTarget ? SelectorEngine.findOne(`#${this.element.dataset.spiritTarget}`) : null; diff --git a/packages/web/src/js/Dropdown.ts b/packages/web/src/js/Dropdown.ts index 73796c72cc..f2536a7e35 100644 --- a/packages/web/src/js/Dropdown.ts +++ b/packages/web/src/js/Dropdown.ts @@ -1,7 +1,7 @@ import BaseComponent from './BaseComponent'; -import { enableToggleTrigger, clickOutsideElement } from './utils'; import EventHandler from './dom/EventHandler'; import SelectorEngine from './dom/SelectorEngine'; +import { clickOutsideElement, enableToggleTrigger, SpiritConfig } from './utils'; interface DropdownStateProps { open: boolean; @@ -30,8 +30,8 @@ class Dropdown extends BaseComponent { state: DropdownStateProps; options: DropdownOptionsProps; - constructor(element: SpiritElement) { - super(element); + constructor(element: SpiritElement, config?: SpiritConfig) { + super(element, config); this.target = SelectorEngine.findOne(`${this.element.dataset.spiritTarget}`); this.reference = this.findReferenceElement(); this.state = { diff --git a/packages/web/src/js/FileUploader.ts b/packages/web/src/js/FileUploader.ts index 79aa2837cd..26623e546a 100644 --- a/packages/web/src/js/FileUploader.ts +++ b/packages/web/src/js/FileUploader.ts @@ -1,6 +1,6 @@ import BaseComponent from './BaseComponent'; import { EventHandler, SelectorEngine } from './dom'; -import { enableToggleAutoloader, image2Base64Preview } from './utils'; +import { SpiritConfig, enableToggleAutoloader, image2Base64Preview } from './utils'; const NAME = 'fileUploader'; const EVENT_KEY = `.${NAME}`; @@ -62,8 +62,8 @@ class FileUploader extends BaseComponent { errors: Record; isDisabled: boolean; - constructor(element: HTMLElement) { - super(element); + constructor(element: HTMLElement, config?: SpiritConfig) { + super(element, config); this.wrapper = SelectorEngine.findOne(WRAPPER_ELEMENT_SELECTOR, element) as HTMLInputElement; this.inputElement = SelectorEngine.findOne(INPUT_ELEMENT_SELECTOR, element) as HTMLInputElement; diff --git a/packages/web/src/js/Modal.ts b/packages/web/src/js/Modal.ts index 0a7b383ca6..0ab0f488c2 100644 --- a/packages/web/src/js/Modal.ts +++ b/packages/web/src/js/Modal.ts @@ -1,7 +1,7 @@ import BaseComponent from './BaseComponent'; import EventHandler from './dom/EventHandler'; import SelectorEngine from './dom/SelectorEngine'; -import { enableToggleTrigger, ScrollControl } from './utils'; +import { enableToggleTrigger, ScrollControl, SpiritConfig } from './utils'; const NAME = 'modal'; @@ -16,8 +16,8 @@ class Modal extends BaseComponent { return NAME; } - constructor(element: HTMLElement | string) { - super(element); + constructor(element: SpiritElement, config?: SpiritConfig) { + super(element, config); this.isShown = false; this.isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; diff --git a/packages/web/src/js/Offcanvas.ts b/packages/web/src/js/Offcanvas.ts index 5ef4daf095..2196ce8503 100644 --- a/packages/web/src/js/Offcanvas.ts +++ b/packages/web/src/js/Offcanvas.ts @@ -1,6 +1,7 @@ +import { breakpoints } from '@lmc-eu/spirit-design-tokens'; import BaseComponent from './BaseComponent'; import EventHandler from './dom/EventHandler'; -import { enableToggleTrigger, enableDismissTrigger, ScrollControl } from './utils'; +import { ScrollControl, SpiritConfig, enableDismissTrigger, enableToggleTrigger } from './utils'; const NAME = 'offcanvas'; const DATA_KEY = 'offcanvas'; @@ -11,23 +12,52 @@ const EVENT_SHOWN = `shown${EVENT_KEY}`; const EVENT_HIDE = `hide${EVENT_KEY}`; const EVENT_HIDDEN = `hidden${EVENT_KEY}`; -const OFFCANVAS_BREAKPOINT = 1280; +const OFFCANVAS_BREAKPOINT = parseInt(breakpoints.desktop, 10); const OPEN_CLASSNAME = 'is-open'; +const VARIABLE_BREAKPOINT_DESKTOP = '--spirit-breakpoint-desktop'; + +const Default = { + breakpointDesktop: OFFCANVAS_BREAKPOINT, +}; + +const DefaultType = { + breakpointDesktop: 'number', +}; + class Offcanvas extends BaseComponent { + breakpoint: number; isShown: boolean; scrollControl: ScrollControl; + static get Default() { + return Default; + } + + static get DefaultType() { + return DefaultType; + } + static get NAME() { return NAME; } - constructor(element: HTMLElement) { + constructor(element: SpiritElement, config?: SpiritConfig) { const target = element; - super(target); + super(target, config); this.isShown = false; this.scrollControl = new ScrollControl(element); + this.breakpoint = this.getBreakpoint(); + } + + getBreakpoint() { + return ( + parseInt(getComputedStyle(document.documentElement).getPropertyValue(VARIABLE_BREAKPOINT_DESKTOP), 10) || + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Object is of type 'unknown'. + this.config?.breakpointDesktop + ); } // Using `unknown` - Object is possibly 'null'. @@ -42,7 +72,7 @@ class Offcanvas extends BaseComponent { } onWindowResize(event: Event & { target: Window }) { - if (event.target.innerWidth >= OFFCANVAS_BREAKPOINT) { + if (event.target.innerWidth >= this.breakpoint) { this.hide(); } } diff --git a/packages/web/src/js/Password.ts b/packages/web/src/js/Password.ts index 9efb506d4e..64f040e73a 100644 --- a/packages/web/src/js/Password.ts +++ b/packages/web/src/js/Password.ts @@ -1,6 +1,6 @@ -import SelectorEngine from './dom/SelectorEngine'; import BaseComponent from './BaseComponent'; -import { enableToggleTrigger } from './utils/ComponentFunctions'; +import SelectorEngine from './dom/SelectorEngine'; +import { enableToggleTrigger, SpiritConfig } from './utils'; const NAME = 'password'; const PASSWORD_ARIA_PRESSED = 'aria-pressed'; @@ -14,8 +14,8 @@ class Password extends BaseComponent { return NAME; } - constructor(element: HTMLElement) { - super(element); + constructor(element: SpiritElement, config?: SpiritConfig) { + super(element, config); this.isShown = false; } diff --git a/packages/web/src/js/ScrollView.ts b/packages/web/src/js/ScrollView.ts index 85d3513b1a..90bd534bfb 100644 --- a/packages/web/src/js/ScrollView.ts +++ b/packages/web/src/js/ScrollView.ts @@ -3,7 +3,7 @@ import BaseComponent from './BaseComponent'; import { EventHandler, SelectorEngine } from './dom'; -import { debounce, enableToggleAutoloader } from './utils'; +import { SpiritConfig, debounce, enableToggleAutoloader } from './utils'; export const Alignment = { LEFT: 'left', @@ -59,8 +59,8 @@ class ScrollView extends BaseComponent { return `${this.NAME}`; } - constructor(element: SpiritElement) { - super(element); + constructor(element: SpiritElement, config?: SpiritConfig) { + super(element, config); this.currentPosition = { bottom: 0, diff --git a/packages/web/src/js/Tabs.ts b/packages/web/src/js/Tabs.ts index 3bc9efa2a0..876f470f60 100644 --- a/packages/web/src/js/Tabs.ts +++ b/packages/web/src/js/Tabs.ts @@ -1,8 +1,7 @@ import BaseComponent from './BaseComponent'; import EventHandler from './dom/EventHandler'; import SelectorEngine from './dom/SelectorEngine'; -import { enableToggleTrigger } from './utils/ComponentFunctions'; -import { getElementFromSelector } from './utils/index'; +import { SpiritConfig, enableToggleTrigger, getElementFromSelector } from './utils'; const NAME = 'tabs'; const DATA_KEY = 'tabs'; @@ -24,8 +23,8 @@ const SELECTOR_INNER_ELEM = `${SELECTOR_INNER}, ${SELECTOR_DATA_TOGGLE}`; class Tabs extends BaseComponent { parent: HTMLElement; - constructor(element: HTMLElement) { - super(element); + constructor(element: SpiritElement, config?: SpiritConfig) { + super(element, config); this.parent = (this.element as Element).closest(SELECTOR_TAB_PANEL) as HTMLElement; if (!this.parent) { diff --git a/packages/web/src/js/Tooltip.ts b/packages/web/src/js/Tooltip.ts index 7f6f2f6ee0..15f22eb6a7 100644 --- a/packages/web/src/js/Tooltip.ts +++ b/packages/web/src/js/Tooltip.ts @@ -1,7 +1,7 @@ import BaseComponent from './BaseComponent'; import EventHandler from './dom/EventHandler'; import SelectorEngine from './dom/SelectorEngine'; -import { enableToggleTrigger, enableDismissTrigger } from './utils/ComponentFunctions'; +import { enableDismissTrigger, enableToggleTrigger, SpiritConfig } from './utils'; const NAME = 'tooltip'; const DATA_KEY = 'tooltip'; @@ -18,8 +18,8 @@ const CLASS_NAME_HIDDEN = 'is-hidden'; class Tooltip extends BaseComponent { tip: HTMLElement; - constructor(element: SpiritElement) { - super(element); + constructor(element: SpiritElement, config?: SpiritConfig) { + super(element, config); this.tip = this.getTipElement(); } diff --git a/packages/web/src/js/dom/Manipulator.ts b/packages/web/src/js/dom/Manipulator.ts new file mode 100644 index 0000000000..2f89cd317e --- /dev/null +++ b/packages/web/src/js/dom/Manipulator.ts @@ -0,0 +1,68 @@ +function normalizeData(value: unknown) { + if (value === 'true') { + return true; + } + + if (value === 'false') { + return false; + } + + if (value === Number(value).toString()) { + return Number(value); + } + + if (value === '' || value === 'null') { + return null; + } + + if (typeof value !== 'string') { + return value; + } + + try { + return JSON.parse(decodeURIComponent(value)); + } catch { + return value; + } +} + +function normalizeDataKey(key: string) { + return key.replace(/[A-Z]/g, (chr) => `-${chr.toLowerCase()}`); +} + +const Manipulator = { + setDataAttribute(element: SpiritElement, key: string, value: string) { + element.setAttribute(`data-spirit-${normalizeDataKey(key)}`, value); + }, + + removeDataAttribute(element: SpiritElement, key: string) { + element.removeAttribute(`data-spirit-${normalizeDataKey(key)}`); + }, + + getDataAttributes(element: SpiritElement) { + if (!element) { + return {}; + } + + const attributes = {}; + const spiritKeys = Object.keys(element.dataset).filter( + (key) => key.startsWith('spirit') && !key.startsWith('spiritConfig'), + ); + + for (const key of spiritKeys) { + let pureKey = key.replace(/^spirit/, ''); + pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1, pureKey.length); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore No index signature with a parameter of type 'string' was found on type '{}'. + attributes[pureKey] = normalizeData(element.dataset[key]); + } + + return attributes; + }, + + getDataAttribute(element: SpiritElement, key: string) { + return normalizeData(element.getAttribute(`data-spirit-${normalizeDataKey(key)}`)); + }, +}; + +export default Manipulator; diff --git a/packages/web/src/js/dom/__tests__/Manipulator.test.ts b/packages/web/src/js/dom/__tests__/Manipulator.test.ts new file mode 100644 index 0000000000..e279924db3 --- /dev/null +++ b/packages/web/src/js/dom/__tests__/Manipulator.test.ts @@ -0,0 +1,140 @@ +import { clearFixture, getFixture } from '../../../../tests/helpers/fixture'; +import Manipulator from '../Manipulator'; + +describe('Manipulator', () => { + let fixtureEl: Element; + + beforeAll(() => { + fixtureEl = getFixture(); + }); + + afterEach(() => { + clearFixture(); + }); + + describe('setDataAttribute', () => { + it('should set data attribute prefixed with spirit', () => { + fixtureEl.innerHTML = '
'; + + const div = fixtureEl.querySelector('div') as HTMLDivElement; + + Manipulator.setDataAttribute(div, 'key', 'value'); + expect(div.getAttribute('data-spirit-key')).toBe('value'); + }); + + it('should set data attribute in kebab case', () => { + fixtureEl.innerHTML = '
'; + + const div = fixtureEl.querySelector('div') as HTMLDivElement; + + Manipulator.setDataAttribute(div, 'testKey', 'value'); + expect(div.getAttribute('data-spirit-test-key')).toBe('value'); + }); + }); + + describe('removeDataAttribute', () => { + it('should only remove spirit-prefixed data attribute', () => { + fixtureEl.innerHTML = '
'; + + const div = fixtureEl.querySelector('div') as HTMLDivElement; + + Manipulator.removeDataAttribute(div, 'key'); + expect(div.getAttribute('data-spirit-key')).toBeNull(); + expect(div.getAttribute('data-key-spirit')).toBe('postfixed'); + expect(div.getAttribute('data-key')).toBe('value'); + }); + + it('should remove data attribute in kebab case', () => { + fixtureEl.innerHTML = '
'; + + const div = fixtureEl.querySelector('div') as HTMLDivElement; + + Manipulator.removeDataAttribute(div, 'testKey'); + expect(div.getAttribute('data-spirit-test-key')).toBeNull(); + }); + }); + + describe('getDataAttributes', () => { + it('should return an empty object for null', () => { + expect(Manipulator.getDataAttributes(null)).toEqual({}); + }); + + it('should get only spirit-prefixed data attributes without spirit namespace', () => { + fixtureEl.innerHTML = + '
'; + + const div = fixtureEl.querySelector('div'); + + expect(Manipulator.getDataAttributes(div)).toEqual({ + toggle: 'tabs', + target: '#element', + }); + }); + + it('should omit `spirit-config` data attribute', () => { + fixtureEl.innerHTML = + '
'; + + const div = fixtureEl.querySelector('div'); + + expect(Manipulator.getDataAttributes(div)).toEqual({ + toggle: 'tabs', + target: '#element', + }); + }); + }); + + describe('getDataAttribute', () => { + it('should only get spirit-prefixed data attribute', () => { + fixtureEl.innerHTML = '
'; + + const div = fixtureEl.querySelector('div'); + + expect(Manipulator.getDataAttribute(div, 'key')).toBe('value'); + expect(Manipulator.getDataAttribute(div, 'test')).toBeNull(); + expect(Manipulator.getDataAttribute(div, 'toggle')).toBeNull(); + }); + + it('should get data attribute in kebab case', () => { + fixtureEl.innerHTML = '
'; + + const div = fixtureEl.querySelector('div'); + + expect(Manipulator.getDataAttribute(div, 'testKey')).toBe('value'); + }); + + it('should normalize data', () => { + fixtureEl.innerHTML = '
'; + + const div = fixtureEl.querySelector('div') as HTMLDivElement; + + expect(Manipulator.getDataAttribute(div, 'test')).toBeFalsy(); + + div.setAttribute('data-spirit-test', 'true'); + expect(Manipulator.getDataAttribute(div, 'test')).toBeTruthy(); + + div.setAttribute('data-spirit-test', '1'); + expect(Manipulator.getDataAttribute(div, 'test')).toBe(1); + }); + + it('should normalize json data', () => { + fixtureEl.innerHTML = '
'; + + const div = fixtureEl.querySelector('div') as HTMLDivElement; + + expect(Manipulator.getDataAttribute(div, 'test')).toEqual({ delay: { show: 100, hide: 10 } }); + + const objectData = { + 'Super Hero': ['Iron Man', 'Super Man'], + testNum: 90, + url: 'http://localhost:8080/test?foo=bar', + }; + const dataStr = JSON.stringify(objectData); + div.setAttribute('data-spirit-test', encodeURIComponent(dataStr)); + expect(Manipulator.getDataAttribute(div, 'test')).toEqual(objectData); + + div.setAttribute('data-spirit-test', dataStr); + expect(Manipulator.getDataAttribute(div, 'test')).toEqual(objectData); + }); + }); +}); diff --git a/packages/web/src/js/utils/Config.ts b/packages/web/src/js/utils/Config.ts new file mode 100644 index 0000000000..43ccc3c8d4 --- /dev/null +++ b/packages/web/src/js/utils/Config.ts @@ -0,0 +1,73 @@ +import Manipulator from '../dom/Manipulator'; +import { isElement } from './Elements'; + +export type SpiritConfig = { [kes: string]: unknown } | null; + +// Shout-out Angus Croll (https://goo.gl/pxwQGp) +const toType = (object: unknown) => { + if (object == null) { + return `${object}`; + } + + return Object.prototype.toString + .call(object) + .match(/\s([a-z]+)/i)[1] + .toLowerCase(); +}; + +class Config { + static get Default(): SpiritConfig { + return {}; + } + + static get DefaultType(): SpiritConfig { + return {}; + } + + static get NAME(): string { + throw new Error('You have to implement the static method "NAME", for each component!'); + } + + getConfig(config: SpiritConfig) { + const mergedConfig = this.mergeConfigObj(config); + this.typeCheckConfig(mergedConfig); + + return mergedConfig; + } + + mergeConfigObj(config?: SpiritConfig, element?: SpiritElement): SpiritConfig { + const jsonConfig = isElement(element) ? Manipulator.getDataAttribute(element, 'config') : {}; // try to parse + + return { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Property 'Default' does not exist on type 'Function'. + ...this.constructor.Default, + ...(typeof jsonConfig === 'object' ? jsonConfig : {}), + ...(isElement(element) ? Manipulator.getDataAttributes(element) : {}), + ...(typeof config === 'object' ? config : {}), + }; + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Property 'DefaultType' does not exist on type 'Function'. + typeCheckConfig(config: SpiritConfig, configTypes: SpiritConfig = this.constructor.DefaultType) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore No overload matches this call. + for (const [property, expectedTypes] of Object.entries(configTypes)) { + const value = config?.[property]; + const valueType = isElement(value) ? 'element' : toType(value); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore No overload matches this call. + if (!new RegExp(expectedTypes).test(valueType)) { + throw new TypeError( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Property 'NAME' does not exist on type 'Function'. + `${this.constructor.NAME.toUpperCase()}: Option "${property}" provided type "${valueType}" but expected type "${expectedTypes}".`, + ); + } + } + } +} + +export default Config; diff --git a/packages/web/src/js/utils/__tests__/Config.test.ts b/packages/web/src/js/utils/__tests__/Config.test.ts new file mode 100644 index 0000000000..f5e489b276 --- /dev/null +++ b/packages/web/src/js/utils/__tests__/Config.test.ts @@ -0,0 +1,185 @@ +import { clearFixture, getFixture } from '../../../../tests/helpers/fixture'; +import Config from '../Config'; + +class DummyConfigClass extends Config { + static get NAME() { + return 'dummy'; + } +} + +describe('Config', () => { + let fixtureEl: Element; + const name = 'dummy'; + + beforeAll(() => { + fixtureEl = getFixture(); + }); + + afterEach(() => { + clearFixture(); + }); + + describe('NAME', () => { + it('should return plugin NAME', () => { + expect(DummyConfigClass.NAME).toEqual(name); + }); + }); + + describe('DefaultType', () => { + it('should return plugin default type', () => { + expect(DummyConfigClass.DefaultType).toEqual({}); + }); + }); + + describe('Default', () => { + it('should return plugin defaults', () => { + expect(DummyConfigClass.Default).toEqual({}); + }); + }); + + describe('mergeConfigObj', () => { + it("should parse element's data attributes and merge it with default config. Element's data attributes must excel Defaults", () => { + fixtureEl.innerHTML = + '
'; + + jest.spyOn(DummyConfigClass, 'Default', 'get').mockReturnValue({ + testBool: true, + testString: 'foo', + testString1: 'foo', + testInt: 7, + }); + + const instance = new DummyConfigClass(); + const configResult = instance.mergeConfigObj({}, fixtureEl.querySelector('#test')); + + expect(configResult?.testBool).toBe(false); + expect(configResult?.testString).toBe('foo'); + expect(configResult?.testString1).toBe('bar'); + expect(configResult?.testInt).toBe(8); + }); + + it("should parse element's data attributes and merge it with default config, plug these given during method call. The programmatically given should excel all", () => { + fixtureEl.innerHTML = + '
'; + + jest.spyOn(DummyConfigClass, 'Default', 'get').mockReturnValue({ + testBool: true, + testString: 'foo', + testString1: 'foo', + testInt: 7, + }); + + const instance = new DummyConfigClass(); + const configResult = instance.mergeConfigObj( + { + testString1: 'test', + testInt: 3, + }, + fixtureEl.querySelector('#test'), + ); + + expect(configResult?.testBool).toBe(false); + expect(configResult?.testString).toBe('foo'); + expect(configResult?.testString1).toBe('test'); + expect(configResult?.testInt).toBe(3); + }); + + it("should parse element's data attribute `config` and any rest attributes. The programmatically given should excel all. Data attribute `config` should excel only Defaults", () => { + fixtureEl.innerHTML = + '
'; + + jest.spyOn(DummyConfigClass, 'Default', 'get').mockReturnValue({ + testBool: true, + testString: 'foo', + testString1: 'foo', + testInt: 7, + testInt2: 600, + }); + + const instance = new DummyConfigClass(); + const configResult = instance.mergeConfigObj( + { + testString1: 'test', + }, + fixtureEl.querySelector('#test'), + ); + + expect(configResult?.testBool).toBe(false); + expect(configResult?.testString).toBe('foo'); + expect(configResult?.testString1).toBe('test'); + expect(configResult?.testInt).toBe(8); + expect(configResult?.testInt2).toBe(100); + }); + + it("should omit element's data attribute `config` if is not an object", () => { + fixtureEl.innerHTML = '
'; + + jest.spyOn(DummyConfigClass, 'Default', 'get').mockReturnValue({ + testInt: 7, + testInt2: 79, + }); + + const instance = new DummyConfigClass(); + const configResult = instance.mergeConfigObj({}, fixtureEl.querySelector('#test')); + + expect(configResult?.testInt).toBe(8); + expect(configResult?.testInt2).toBe(79); + }); + }); + + describe('typeCheckConfig', () => { + it('should check type of the config object', () => { + jest.spyOn(DummyConfigClass, 'DefaultType', 'get').mockReturnValue({ + toggle: 'boolean', + parent: '(string|element)', + }); + const config = { + toggle: true, + parent: 777, + }; + + const obj = new DummyConfigClass(); + expect(() => { + obj.typeCheckConfig(config); + }).toThrow( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Property 'NAME' does not exist on type 'Function'. + `${obj.constructor.NAME.toUpperCase()}: Option "parent" provided type "number" but expected type "(string|element)".`, + ); + }); + + it('should return null stringified when null is passed', () => { + jest.spyOn(DummyConfigClass, 'DefaultType', 'get').mockReturnValue({ + toggle: 'boolean', + parent: '(null|element)', + }); + + const obj = new DummyConfigClass(); + const config = { + toggle: true, + parent: null, + }; + + expect(() => { + obj.typeCheckConfig(config); + }).not.toThrow(); + }); + + it('should return undefined stringified when undefined is passed', () => { + jest.spyOn(DummyConfigClass, 'DefaultType', 'get').mockReturnValue({ + toggle: 'boolean', + parent: '(undefined|element)', + }); + + const obj = new DummyConfigClass(); + const config = { + toggle: true, + parent: undefined, + }; + + expect(() => { + obj.typeCheckConfig(config); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/web/src/js/utils/index.ts b/packages/web/src/js/utils/index.ts index acff4c2756..d1fdd621ec 100644 --- a/packages/web/src/js/utils/index.ts +++ b/packages/web/src/js/utils/index.ts @@ -9,11 +9,13 @@ const reflow = (element: HTMLElement): void => { element.offsetHeight; }; -export { reflow }; export * from './ComponentFunctions'; +export * from './Config'; +export { default as Config } from './Config'; export * from './Debounce'; export * from './Deprecations'; export * from './Elements'; +export * from './Image2Base64Preview'; export { default as ScrollControl } from './ScrollControl'; export * from './Transitions'; -export * from './Image2Base64Preview'; +export { reflow }; diff --git a/packages/web/src/scss/components/Header/README.md b/packages/web/src/scss/components/Header/README.md index ed24185f90..b6b526de19 100644 --- a/packages/web/src/scss/components/Header/README.md +++ b/packages/web/src/scss/components/Header/README.md @@ -34,6 +34,9 @@ plugins. Or, feel free to write the controlling script yourself. +The HeaderDialog uses the Offcanvas JS Plugin to toggle the dialog. +See [Offcanvas documentation][offcanvas-docs] for more details. + ## Accessibility Guidelines - Ensure accessibility by using a `