Skip to content

Commit cbf6208

Browse files
feat: Support CSS modules (#1220)
* feat(all): **Mostly** Support CSS modules * feat(css-modules): DatePicker needs list styles * feat(css-modules): Fix remaining issues * style(lint): Remove eslint-disable-line and lint for some bare className props * feat(css-modules): Update snaps, increase size, use classnames query selector * feat(css-modules): Only add double styles when using css modules * feat(css-modules): Only add double styles when using css modules in storysnaps * feat(css-modules): Only add double styles when using css modules in storysnaps II * docs(readme): Include readme about css-modules
1 parent fceec03 commit cbf6208

File tree

127 files changed

+987
-343
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

127 files changed

+987
-343
lines changed

.eslintrc

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"ie11",
1212
"jsx-a11y",
1313
"loosely-restrict-imports",
14-
"react"
14+
"react",
15+
"eslint-plugin-local-rules"
1516
],
1617
"settings": {
1718
"react": {
@@ -152,15 +153,17 @@
152153
],
153154
"semi-spacing": 2,
154155
"semi": 2,
155-
"sort-imports": [2,
156+
"local-rules/classnames-wrap": 2,
157+
"local-rules/sort-imports": [2,
156158
{
157159
"ignoreCase": true,
158160
"ignoreMemberSort": false,
159161
"memberSyntaxSortOrder": [
160162
"single",
161163
"multiple",
162164
"all",
163-
"none"
165+
"none",
166+
"css"
164167
]
165168
}
166169
],

.size-limit

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33
name: "Fundamental-React Size",
44
webpack: true,
55
path: "lib/index.js",
6-
limit: "240 KB"
6+
limit: "241 KB"
77
}
88
]

.storybook/main.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ module.exports = {
3232
include: path.resolve(__dirname, '../'),
3333
});
3434

35+
config.module.rules
36+
.find(rule => rule.test.toString() === /\.css$/.toString())
37+
.use = [
38+
'style-loader',
39+
{
40+
loader: 'css-loader',
41+
options: {
42+
modules: {
43+
localIdentName: '[local]-[sha1:hash:hex:6]'
44+
}
45+
}
46+
}
47+
];
48+
3549
return merge(config, {
3650
optimization: {
3751
splitChunks: {

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,27 @@ The following circumstances will NOT be considered a **BREAKING** change:
9191
* Non-visual HTML attribute changes/additions (such as `role`, `aria-*`, `data-*`)
9292
* An existing unit test is altered to account for non-visual HTML attribute changes/additions (such as `role`, `aria-*`, `data-*`)
9393

94+
## CSS Modules
95+
96+
This library supports [css-modules](https://github.com/css-modules/css-modules). The motivation for this support is to be able to include multiple versions or instances of fundamental styles on the same page without collisions in styles. This can be useful if you have a page using fundamental-ngx alongside fundamental-react, for example.
97+
98+
One way to use fundamental-react with hashed class names is to pass the library code through css-loader in your webpack config
99+
```js
100+
{
101+
loader: 'css-loader',
102+
include: [
103+
path.resolve(__dirname, 'node_modules/fundamental-react')
104+
],
105+
options: {
106+
modules: {
107+
localIdentName: '[local]-[sha1:hash:hex:6]'
108+
}
109+
}
110+
}
111+
```
112+
113+
It's important to include `[local]` in the localIdentName which keeps the class name in the hash. This is because some of the style rules in fundamental-styles reference the name of the class, like `[class*=level]`.
114+
94115
## Known Issues
95116
96117
Please see [Issues](https://github.com/SAP/fundamental-react/issues).

config/jest/CSSStub.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = {};

config/jest/jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ module.exports = {
66
setupFiles: ['../config/jest/setup.js'],
77
moduleFileExtensions: ['js', 'json', 'jsx', 'css', 'node'],
88
moduleNameMapper: {
9-
'^.+\\.(css)$': 'babel-jest'
9+
'^.+\\.(css)$': '<rootDir>/../config/jest/CSSStub.js'
1010
}
1111
};

eslint-local-rules.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const classnamesWrap = require('./local-rules/classnames-wrap');
2+
const sortImports = require('./local-rules/sort-imports');
3+
// This will make rules accessible in eslintrc as `local-rules/classnames-wrap`
4+
// see eslint-plugin-local-rules https://github.com/cletusw/eslint-plugin-local-rules for more info
5+
module.exports = {
6+
'classnames-wrap': classnamesWrap,
7+
'sort-imports': sortImports
8+
};

local-rules/classnames-wrap.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module.exports = {
2+
create: (context) => ({
3+
JSXAttribute(node) {
4+
const propName = node.name && node.name.name;
5+
if (node.value && node.value.expression) {
6+
const isLiteralOrTemplateLiteral = node.value.expression.type.includes('Literal');
7+
if (propName === 'className' && isLiteralOrTemplateLiteral) {
8+
context.report({
9+
node,
10+
message: 'The className prop must be wrapped in "classnames()" to support css-modules'
11+
});
12+
}
13+
}
14+
}
15+
})
16+
};

local-rules/sort-imports.js

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
// copied from 'https://eslint.org/docs/rules/sort-imports'
2+
// "css" was added to the sort order to enforce `.css` imports last
3+
4+
module.exports = {
5+
meta: {
6+
type: 'suggestion',
7+
8+
docs: {
9+
description: 'enforce sorted import declarations within modules',
10+
category: 'ECMAScript 6',
11+
recommended: false,
12+
url: 'https://eslint.org/docs/rules/sort-imports'
13+
},
14+
15+
schema: [
16+
{
17+
type: 'object',
18+
properties: {
19+
ignoreCase: {
20+
type: 'boolean',
21+
'default': false
22+
},
23+
memberSyntaxSortOrder: {
24+
type: 'array',
25+
items: {
26+
'enum': ['none', 'all', 'multiple', 'single', 'css']
27+
},
28+
uniqueItems: true,
29+
minItems: 5,
30+
maxItems: 5
31+
},
32+
ignoreDeclarationSort: {
33+
type: 'boolean',
34+
'default': false
35+
},
36+
ignoreMemberSort: {
37+
type: 'boolean',
38+
'default': false
39+
},
40+
allowSeparatedGroups: {
41+
type: 'boolean',
42+
'default': false
43+
}
44+
},
45+
additionalProperties: false
46+
}
47+
],
48+
49+
fixable: 'code',
50+
51+
messages: {
52+
sortImportsAlphabetically: 'Imports should be sorted alphabetically.',
53+
sortMembersAlphabetically:
54+
'Member "{{memberName}}" of the import declaration should be sorted alphabetically.',
55+
unexpectedSyntaxOrder: 'Expected "{{syntaxA}}" syntax before "{{syntaxB}}" syntax.'
56+
}
57+
},
58+
59+
create(context) {
60+
const configuration = context.options[0] || {},
61+
ignoreCase = configuration.ignoreCase || false,
62+
ignoreDeclarationSort = configuration.ignoreDeclarationSort || false,
63+
ignoreMemberSort = configuration.ignoreMemberSort || false,
64+
memberSyntaxSortOrder = configuration.memberSyntaxSortOrder || [
65+
'none',
66+
'all',
67+
'multiple',
68+
'single'
69+
],
70+
allowSeparatedGroups = configuration.allowSeparatedGroups || false,
71+
sourceCode = context.getSourceCode();
72+
let previousDeclaration = null;
73+
74+
/**
75+
* Gets the used member syntax style.
76+
*
77+
* import "my-module.js" --> none
78+
* import * as myModule from "my-module.js" --> all
79+
* import {myMember} from "my-module.js" --> single
80+
* import {foo, bar} from "my-module.js" --> multiple
81+
* @param {ASTNode} node the ImportDeclaration node.
82+
* @returns {string} used member parameter style, ["all", "multiple", "single"]
83+
*/
84+
function usedMemberSyntax(node) {
85+
if (node.source.value.match(/\.css/)) {
86+
return 'css';
87+
}
88+
if (node.specifiers.length === 0) {
89+
return 'none';
90+
}
91+
if (node.specifiers[0].type === 'ImportNamespaceSpecifier') {
92+
return 'all';
93+
}
94+
if (node.specifiers.length === 1) {
95+
return 'single';
96+
}
97+
return 'multiple';
98+
}
99+
100+
/**
101+
* Gets the group by member parameter index for given declaration.
102+
* @param {ASTNode} node the ImportDeclaration node.
103+
* @returns {number} the declaration group by member index.
104+
*/
105+
function getMemberParameterGroupIndex(node) {
106+
return memberSyntaxSortOrder.indexOf(usedMemberSyntax(node));
107+
}
108+
109+
/**
110+
* Gets the local name of the first imported module.
111+
* @param {ASTNode} node the ImportDeclaration node.
112+
* @returns {?string} the local name of the first imported module.
113+
*/
114+
function getFirstLocalMemberName(node) {
115+
if (node.specifiers[0]) {
116+
return node.specifiers[0].local.name;
117+
}
118+
return null;
119+
}
120+
121+
/**
122+
* Calculates number of lines between two nodes. It is assumed that the given `left` node appears before
123+
* the given `right` node in the source code. Lines are counted from the end of the `left` node till the
124+
* start of the `right` node. If the given nodes are on the same line, it returns `0`, same as if they were
125+
* on two consecutive lines.
126+
* @param {ASTNode} left node that appears before the given `right` node.
127+
* @param {ASTNode} right node that appears after the given `left` node.
128+
* @returns {number} number of lines between nodes.
129+
*/
130+
function getNumberOfLinesBetween(left, right) {
131+
return Math.max(right.loc.start.line - left.loc.end.line - 1, 0);
132+
}
133+
134+
return {
135+
ImportDeclaration(node) {
136+
if (!ignoreDeclarationSort) {
137+
if (
138+
previousDeclaration &&
139+
allowSeparatedGroups &&
140+
getNumberOfLinesBetween(previousDeclaration, node) > 0
141+
) {
142+
// reset declaration sort
143+
previousDeclaration = null;
144+
}
145+
146+
if (previousDeclaration) {
147+
const currentMemberSyntaxGroupIndex = getMemberParameterGroupIndex(node),
148+
previousMemberSyntaxGroupIndex = getMemberParameterGroupIndex(
149+
previousDeclaration
150+
);
151+
let currentLocalMemberName = getFirstLocalMemberName(node),
152+
previousLocalMemberName = getFirstLocalMemberName(previousDeclaration);
153+
154+
if (ignoreCase) {
155+
previousLocalMemberName =
156+
previousLocalMemberName && previousLocalMemberName.toLowerCase();
157+
currentLocalMemberName =
158+
currentLocalMemberName && currentLocalMemberName.toLowerCase();
159+
}
160+
161+
/*
162+
* When the current declaration uses a different member syntax,
163+
* then check if the ordering is correct.
164+
* Otherwise, make a default string compare (like rule sort-vars to be consistent) of the first used local member name.
165+
*/
166+
if (currentMemberSyntaxGroupIndex !== previousMemberSyntaxGroupIndex) {
167+
if (currentMemberSyntaxGroupIndex < previousMemberSyntaxGroupIndex) {
168+
context.report({
169+
node,
170+
messageId: 'unexpectedSyntaxOrder',
171+
data: {
172+
syntaxA:
173+
memberSyntaxSortOrder[currentMemberSyntaxGroupIndex],
174+
syntaxB:
175+
memberSyntaxSortOrder[previousMemberSyntaxGroupIndex]
176+
}
177+
});
178+
}
179+
} else {
180+
if (
181+
previousLocalMemberName &&
182+
currentLocalMemberName &&
183+
currentLocalMemberName < previousLocalMemberName
184+
) {
185+
context.report({
186+
node,
187+
messageId: 'sortImportsAlphabetically'
188+
});
189+
}
190+
}
191+
}
192+
193+
previousDeclaration = node;
194+
}
195+
196+
if (!ignoreMemberSort) {
197+
const importSpecifiers = node.specifiers.filter(
198+
(specifier) => specifier.type === 'ImportSpecifier'
199+
);
200+
const getSortableName = ignoreCase
201+
? (specifier) => specifier.local.name.toLowerCase()
202+
: (specifier) => specifier.local.name;
203+
const firstUnsortedIndex = importSpecifiers
204+
.map(getSortableName)
205+
.findIndex((name, index, array) => array[index - 1] > name);
206+
207+
if (firstUnsortedIndex !== -1) {
208+
context.report({
209+
node: importSpecifiers[firstUnsortedIndex],
210+
messageId: 'sortMembersAlphabetically',
211+
data: { memberName: importSpecifiers[firstUnsortedIndex].local.name },
212+
fix(fixer) {
213+
if (
214+
importSpecifiers.some(
215+
(specifier) =>
216+
sourceCode.getCommentsBefore(specifier).length ||
217+
sourceCode.getCommentsAfter(specifier).length
218+
)
219+
) {
220+
// If there are comments in the ImportSpecifier list, don't rearrange the specifiers.
221+
return null;
222+
}
223+
224+
return fixer.replaceTextRange(
225+
[
226+
importSpecifiers[0].range[0],
227+
importSpecifiers[importSpecifiers.length - 1].range[1]
228+
],
229+
importSpecifiers
230+
231+
// Clone the importSpecifiers array to avoid mutating it
232+
.slice()
233+
234+
// Sort the array into the desired order
235+
.sort((specifierA, specifierB) => {
236+
const aName = getSortableName(specifierA);
237+
const bName = getSortableName(specifierB);
238+
239+
return aName > bName ? 1 : -1;
240+
})
241+
242+
// Build a string out of the sorted list of import specifiers and the text between the originals
243+
.reduce((sourceText, specifier, index) => {
244+
const textAfterSpecifier =
245+
index === importSpecifiers.length - 1
246+
? ''
247+
: sourceCode
248+
.getText()
249+
.slice(
250+
importSpecifiers[index].range[1],
251+
importSpecifiers[index + 1].range[0]
252+
);
253+
254+
return (
255+
sourceText +
256+
sourceCode.getText(specifier) +
257+
textAfterSpecifier
258+
);
259+
}, '')
260+
);
261+
}
262+
});
263+
}
264+
}
265+
}
266+
};
267+
}
268+
};

0 commit comments

Comments
 (0)