diff --git a/.eslintplugin.js b/.eslintplugin.js index 2b3949ff08f..50632e51018 100644 --- a/.eslintplugin.js +++ b/.eslintplugin.js @@ -3,4 +3,5 @@ exports.rules = { 'href-with-rel': require('./scripts/eslint-plugin/rel'), 'require-license-header': require('./scripts/eslint-plugin/require_license_header'), 'forward-ref': require('./scripts/eslint-plugin/forward_ref_display_name'), + 'css-logical-properties': require('./scripts/eslint-plugin/css_logical_properties'), }; diff --git a/.eslintrc.js b/.eslintrc.js index 04f93eae965..fd2ee1beca0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -44,6 +44,7 @@ module.exports = { 'local/i18n': 'error', 'local/href-with-rel': 'error', 'local/forward-ref': 'error', + 'local/css-logical-properties': 'error', 'local/require-license-header': [ 'warn', { diff --git a/scripts/eslint-plugin/css_logical_properties.js b/scripts/eslint-plugin/css_logical_properties.js new file mode 100644 index 00000000000..c301a658854 --- /dev/null +++ b/scripts/eslint-plugin/css_logical_properties.js @@ -0,0 +1,123 @@ +const logicals = require('../../src/global_styling/functions/logicals.json'); +const logicalProperties = Object.keys(logicals); + +const logicalValues = { + 'text-align: left': 'text-align: start', + 'text-align: right': 'text-align: end', + // TODO: Consider adding float, clear, & resize as well + // @see https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Logical_Properties +}; + +const logicalPropertiesRegex = logicalProperties.join('|'); +const logicalValuesRegex = Object.keys(logicalValues).join('|'); +// @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec +// @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Groups_and_Backreferences +const regex = new RegExp( + `^(?[\\s]*)((?${logicalPropertiesRegex}):)|(?${logicalValuesRegex})`, + 'gm' +); + +const logicalsFixMap = { ...logicals, ...logicalValues }; + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Enforce using CSS logical properties in our Emotion CSS', + }, + messages: { + preferLogicalProperty: + 'Prefer the CSS logical property for {{ property }} - @see src/global_styling/functions/logicals.ts', + preferLogicalValue: + 'Prefer the CSS logical value for {{ property }} - @see src/global_styling/functions/logicals.ts', + }, + fixable: 'code', + // NOTE: To disable this lint rule for a single line/property within a css`` block + // your code must use a comment inside a template literal, e.g.: + // css` + // color: red; + // height: 40px; ${/* eslint-disable-line local/css-logical-properties */ ''} + // ` + }, + create: function (context) { + return { + TemplateLiteral(node) { + const templateContents = node.quasis || []; + templateContents.forEach((quasi) => { + const stringLiteral = quasi?.value?.raw; + if (!stringLiteral) return; + + findOccurrences(regex, stringLiteral).forEach( + ({ match, lineNumber, column }) => { + const property = match.groups.property || match.groups.value; + const whitespace = match.groups.whitespace?.length || 0; + + const lineStart = quasi.loc.start.line + lineNumber; + const columnStart = column + whitespace; + + context.report({ + loc: { + start: { + line: lineStart, + column: columnStart, + }, + end: { + line: lineStart, + column: columnStart + property.length, + }, + }, + messageId: match.groups.value + ? 'preferLogicalValue' + : 'preferLogicalProperty', + data: { property }, + fix: function (fixer) { + const literalStart = quasi.range[0] + 1; // Account for backtick + const indexStart = literalStart + match.index + whitespace; + + return fixer.replaceTextRange( + [indexStart, indexStart + property.length], + logicalsFixMap[property] + ); + }, + }); + } + ); + }); + }, + }; + }, +}; + +/** + * Regex helpers for finding the location of a property + * (vs highlighting the entire css`` node) + * + * credit to https://stackoverflow.com/a/61725880/4294462 + */ + +const lineNumberByIndex = (index, string) => { + const re = /^[\S\s]/gm; + let line = 0, + match; + let lastRowIndex = 0; + while ((match = re.exec(string))) { + if (match.index > index) break; + lastRowIndex = match.index; + line++; + } + return [Math.max(line - 1, 0), lastRowIndex]; +}; + +const findOccurrences = (needle, haystack) => { + let match; + const result = []; + while ((match = needle.exec(haystack))) { + const pos = lineNumberByIndex(needle.lastIndex, haystack); + result.push({ + match, + lineNumber: pos[0], + column: needle.lastIndex - pos[1] - match[0].length, + }); + } + return result; +}; diff --git a/scripts/eslint-plugin/css_logical_properties.test.js b/scripts/eslint-plugin/css_logical_properties.test.js new file mode 100644 index 00000000000..b31dfd86c34 --- /dev/null +++ b/scripts/eslint-plugin/css_logical_properties.test.js @@ -0,0 +1,99 @@ +import rule from './css_logical_properties.js'; +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('babel-eslint'), +}); + +const valid = [ + `css\` + inline-size: 50px; + inline-start-end: 10px; + \``, + // Make sure we don't incorrectly catch similar properties that do not have logical equivalents + `\` + line-height: 20px; + border-width: 1px; + scrollbar-width: 30px + \``, +]; + +const invalid = [ + { + code: 'css`height: 50px;`', + output: 'css`block-size: 50px;`', + errors: [{ messageId: 'preferLogicalProperty' }], + }, + { + code: '`max-height: 50px;`', + output: '`max-block-size: 50px;`', + errors: [{ messageId: 'preferLogicalProperty' }], + }, + { + code: 'css`width: 50px;`', + output: 'css`inline-size: 50px;`', + errors: [{ messageId: 'preferLogicalProperty' }], + }, + { + code: '`min-width: 50px;`', + output: '`min-inline-size: 50px;`', + errors: [{ messageId: 'preferLogicalProperty' }], + }, + { + code: 'css`top: 0;`', + output: 'css`inset-block-start: 0;`', + errors: [{ messageId: 'preferLogicalProperty' }], + }, + { + code: 'css`padding-right: 0;`', + output: 'css`padding-inline-end: 0;`', + errors: [{ messageId: 'preferLogicalProperty' }], + }, + { + code: 'css`margin-bottom: 0;`', + output: 'css`margin-block-end: 0;`', + errors: [{ messageId: 'preferLogicalProperty' }], + }, + { + code: 'css`border-left: 1px solid green;`', + output: 'css`border-inline-start: 1px solid green;`', + errors: [{ messageId: 'preferLogicalProperty' }], + }, + { + code: 'css`border-left-color: red;`', + output: 'css`border-inline-start-color: red;`', + errors: [{ messageId: 'preferLogicalProperty' }], + }, + { + code: 'css`text-align: left;`', + output: 'css`text-align: start;`', + errors: [{ messageId: 'preferLogicalValue' }], + }, + { + code: 'css`overflow-y: hidden;`', + output: 'css`overflow-block: hidden;`', + errors: [{ messageId: 'preferLogicalProperty' }], + }, + // Test multiple errors + { + code: `css\` + content: 'ok'; + text-align: right; + bottom: 50px; + \``, + output: `css\` + content: 'ok'; + text-align: end; + inset-block-end: 50px; + \``, + errors: [ + { messageId: 'preferLogicalValue' }, + { messageId: 'preferLogicalProperty' }, + ], + }, +]; + +ruleTester.run('css_logical_properties', rule, { + valid, + invalid, +}); diff --git a/src-docs/src/views/auto_sizer/auto_sizer.tsx b/src-docs/src/views/auto_sizer/auto_sizer.tsx index 87c053cfc13..d0dc6f35cff 100644 --- a/src-docs/src/views/auto_sizer/auto_sizer.tsx +++ b/src-docs/src/views/auto_sizer/auto_sizer.tsx @@ -1,12 +1,16 @@ import React from 'react'; import { css } from '@emotion/react'; -import { EuiAutoSizer, EuiCode, EuiPanel } from '../../../../src/components'; +import { + EuiAutoSizer, + EuiCode, + EuiPanel, + logicalSizeCSS, +} from '../../../../src'; export default () => { const containerStyles = css` - height: 200px; - width: 100%; + ${logicalSizeCSS('100%', '200px')} `; const panelStyles = css` @@ -21,7 +25,9 @@ export default () => { {({ height, width }) => ( - {`height: ${height}, width: ${width}`} + + height: {height}, width: {width} + )} diff --git a/src-docs/src/views/comment/comment_props.tsx b/src-docs/src/views/comment/comment_props.tsx index 4c2c8c1b261..fdab7af11d1 100644 --- a/src-docs/src/views/comment/comment_props.tsx +++ b/src-docs/src/views/comment/comment_props.tsx @@ -23,8 +23,8 @@ export default ({ snippet }: { snippet: ReactNode }) => { { ${euiTheme.border.radius.small} 0 0; padding: ${euiTheme.size.s}; background: ${euiTheme.colors.lightestShade}; - border-bottom: ${euiTheme.border.thin}; + ${logicalCSS('border-bottom', euiTheme.border.thin)} display: flex; `} > diff --git a/src-docs/src/views/datagrid/_snippets.tsx b/src-docs/src/views/datagrid/_snippets.tsx index 57097112053..1680b638853 100644 --- a/src-docs/src/views/datagrid/_snippets.tsx +++ b/src-docs/src/views/datagrid/_snippets.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; +/* eslint-disable local/css-logical-properties */ export const gridSnippets = { inMemory: `// Will try to autodectect schemas and do sorting and pagination in memory. inMemory={{ level: 'sorting' }}`, diff --git a/src-docs/src/views/datagrid/schema_columns/datagrid_columns_example.js b/src-docs/src/views/datagrid/schema_columns/datagrid_columns_example.js index cc5f073c1a9..ef65697d7fe 100644 --- a/src-docs/src/views/datagrid/schema_columns/datagrid_columns_example.js +++ b/src-docs/src/views/datagrid/schema_columns/datagrid_columns_example.js @@ -29,6 +29,7 @@ import { EuiDataGridCellValueElementProps, } from '!!prop-loader!../../../../../src/components/datagrid/data_grid_types'; +/* eslint-disable local/css-logical-properties */ const gridLeadingColumnsSnippet = `; comments: number; created_at: string; - body?: string; -} - -// convert strings to Date objects -for (let i = 0; i < githubData.length; i++) { - githubData[i].created_at = new Date(githubData[i].created_at); + body: null | string; } type DataContextShape = @@ -110,7 +104,8 @@ const RenderCellValue: EuiDataGridProps['renderCellValue'] = ({ imageUrl={item.user.avatar_url} size="s" />{' '} - {item.user.login} on {formatDate(item.created_at, 'dobLong')} + {item.user.login} on{' '} + {formatDate(new Date(item.created_at), 'dobLong')} diff --git a/src-docs/src/views/datagrid/styling/row_height_fixed.tsx b/src-docs/src/views/datagrid/styling/row_height_fixed.tsx index 7c668911b51..e46dfb452dc 100644 --- a/src-docs/src/views/datagrid/styling/row_height_fixed.tsx +++ b/src-docs/src/views/datagrid/styling/row_height_fixed.tsx @@ -6,7 +6,6 @@ import React, { useMemo, ReactNode, } from 'react'; -// @ts-ignore not configured to import json import githubData from '../_row_auto_height_data.json'; import { @@ -34,12 +33,7 @@ interface DataShape { }>; comments: number; created_at: string; - body?: string; -} - -// convert strings to Date objects -for (let i = 0; i < githubData.length; i++) { - githubData[i].created_at = new Date(githubData[i].created_at); + body: null | string; } type DataContextShape = @@ -110,7 +104,8 @@ const RenderCellValue: EuiDataGridProps['renderCellValue'] = ({ imageUrl={item.user.avatar_url} size="s" />{' '} - {item.user.login} on {formatDate(item.created_at, 'dobLong')} + {item.user.login} on{' '} + {formatDate(new Date(item.created_at), 'dobLong')} diff --git a/src-docs/src/views/datagrid/styling/row_line_height.tsx b/src-docs/src/views/datagrid/styling/row_line_height.tsx index 3e29d040635..636c21e42df 100644 --- a/src-docs/src/views/datagrid/styling/row_line_height.tsx +++ b/src-docs/src/views/datagrid/styling/row_line_height.tsx @@ -6,7 +6,6 @@ import React, { useMemo, ReactNode, } from 'react'; -// @ts-ignore not configured to import json import githubData from '../_row_auto_height_data.json'; import { EuiDataGrid, EuiDataGridProps, formatDate } from '../../../../../src'; @@ -24,12 +23,7 @@ interface DataShape { }>; comments: number; created_at: string; - body?: string; -} - -// convert strings to Date objects -for (let i = 0; i < githubData.length; i++) { - githubData[i].created_at = new Date(githubData[i].created_at); + body: null | string; } type DataContextShape = @@ -78,7 +72,8 @@ const RenderCellValue: EuiDataGridProps['renderCellValue'] = ({ <> {item.title}
- Opened by {item.user.login} on {formatDate(item.created_at, 'dobLong')} + Opened by {item.user.login} on{' '} + {formatDate(new Date(item.created_at), 'dobLong')}
{item.comments} comment{item.comments !== 1 ? 's' : ''} diff --git a/src-docs/src/views/datagrid/toolbar/_props.tsx b/src-docs/src/views/datagrid/toolbar/_props.tsx index ad24442e05e..87671f9502c 100644 --- a/src-docs/src/views/datagrid/toolbar/_props.tsx +++ b/src-docs/src/views/datagrid/toolbar/_props.tsx @@ -4,6 +4,7 @@ import { EuiDataGridToolBarVisibilityOptions } from '!!prop-loader!../../../../. import { DataGridPropsTable } from '../_props_table'; +/* eslint-disable local/css-logical-properties */ const gridSnippets = { showColumnSelector: `showColumnSelector: { allowHide: false; diff --git a/src-docs/src/views/datagrid/toolbar/datagrid_toolbar_example.js b/src-docs/src/views/datagrid/toolbar/datagrid_toolbar_example.js index 57ef4988bbb..1a36b21cc05 100644 --- a/src-docs/src/views/datagrid/toolbar/datagrid_toolbar_example.js +++ b/src-docs/src/views/datagrid/toolbar/datagrid_toolbar_example.js @@ -19,6 +19,7 @@ import { EuiDataGridToolBarAdditionalControlsLeftOptions, } from '!!prop-loader!../../../../../src/components/datagrid/data_grid_types'; +/* eslint-disable local/css-logical-properties */ const controlsSnippet = ` { }, }; + /* eslint-disable local/css-logical-properties */ + const defaultAlignmentToCopy = `alignment: { vertical: 'middle', horizontal: 'center', diff --git a/src-docs/src/views/resize_observer/resize_observer.js b/src-docs/src/views/resize_observer/resize_observer.js index b97f3c5158e..2dc80a6576c 100644 --- a/src-docs/src/views/resize_observer/resize_observer.js +++ b/src-docs/src/views/resize_observer/resize_observer.js @@ -33,7 +33,9 @@ export const ResizeObserverExample = () => {

- {`height: ${height}; width: ${width}`} + + height: {height}; width: ${width} +

diff --git a/src-docs/src/views/resize_observer/resize_observer_hook.js b/src-docs/src/views/resize_observer/resize_observer_hook.js index 67952605c03..12f9ae8c187 100644 --- a/src-docs/src/views/resize_observer/resize_observer_hook.js +++ b/src-docs/src/views/resize_observer/resize_observer_hook.js @@ -42,7 +42,9 @@ export const ResizeObserverHookExample = () => {

)}

- {`height: ${dimensions.height}; width: ${dimensions.width}`} + + height: {dimensions.height}; width: {dimensions.width} +

diff --git a/src-docs/src/views/scroll/scroll.tsx b/src-docs/src/views/scroll/scroll.tsx index a7d2cfa5902..5058f8fe2b0 100644 --- a/src-docs/src/views/scroll/scroll.tsx +++ b/src-docs/src/views/scroll/scroll.tsx @@ -3,7 +3,13 @@ import React, { useContext } from 'react'; import { ThemeContext } from '../../components/with_theme'; -import { EuiCode, useEuiScrollBar, useEuiTheme } from '../../../../src'; +import { + EuiCode, + useEuiScrollBar, + useEuiTheme, + logicalCSS, + logicalCSSWithFallback, +} from '../../../../src'; import { ThemeExample } from '../theme/_components/_theme_example'; import { ScrollContent } from './_scroll_content'; @@ -40,7 +46,8 @@ export default () => { className="eui-scrollBar" style={{ overflowY: 'auto', - height: euiTheme.base * 10, + overflowBlock: 'auto', + blockSize: euiTheme.base * 10, }} > @@ -51,7 +58,7 @@ export default () => { role="region" aria-label="" className="eui-scrollBar" - style={{ overflowY: 'auto', euiTheme.base * 10 }}> +> @@ -85,8 +92,8 @@ export default () => { aria-label="Example of useEuiScrollBar region" css={css` ${useEuiScrollBar()} - overflow-y: auto; - height: ${euiTheme.base * 10}px; + ${logicalCSSWithFallback('overflow-y', 'auto')} + ${logicalCSS('height', `${euiTheme.base * 10}px`)} `} > @@ -130,8 +137,9 @@ export default () => { snippetLanguage="scss" snippet={`.scrollBarRegion { @include euiScrollBar; - overflow-y: auto; - height: $euiSize * 10; + overflow-y: auto; ${/* eslint-disable-line local/css-logical-properties */ ''} + overflow-block: auto; + block-size: $euiSize * 10; }`} /> )} diff --git a/src-docs/src/views/scroll/scroll_x.tsx b/src-docs/src/views/scroll/scroll_x.tsx index e932a402dfd..81607bd8989 100644 --- a/src-docs/src/views/scroll/scroll_x.tsx +++ b/src-docs/src/views/scroll/scroll_x.tsx @@ -9,6 +9,7 @@ import { useEuiTheme, EuiLink, useEuiOverflowScroll, + logicalCSS, } from '../../../../src'; import { ThemeExample } from '../theme/_components/_theme_example'; import { ScrollContent } from './_scroll_content'; @@ -23,7 +24,7 @@ export default () => { const scrollingContent = ( @@ -53,25 +54,21 @@ export default () => { role="region" aria-label="Example of eui-xScroll region" className="eui-xScrollWithShadows" - style={{ padding: `${euiTheme.size.base}` }} + style={{ padding: euiTheme.size.base }} > {scrollingContent}
} - snippet={ - `
+> -
` - } +`} /> {!showSass && ( @@ -107,20 +104,8 @@ export default () => { {scrollingContent} } - snippet={ - `
- - - -
` - } + snippet="${useEuiOverflowScroll('x', true)}" + snippetLanguage="emotion" /> )} diff --git a/src-docs/src/views/scroll/scroll_y.tsx b/src-docs/src/views/scroll/scroll_y.tsx index c022aabfed9..47f0edd6aae 100644 --- a/src-docs/src/views/scroll/scroll_y.tsx +++ b/src-docs/src/views/scroll/scroll_y.tsx @@ -3,7 +3,12 @@ import { css } from '@emotion/react'; import { ThemeContext } from '../../components/with_theme'; -import { EuiCode, EuiLink, useEuiOverflowScroll } from '../../../../src'; +import { + EuiCode, + EuiLink, + useEuiOverflowScroll, + logicalCSS, +} from '../../../../src'; import { ThemeExample } from '../theme/_components/_theme_example'; import { ScrollContent } from './_scroll_content'; @@ -43,7 +48,7 @@ export default () => { role="region" aria-label="" className="eui-yScrollWithShadows" - style={{ height: 180 }}> +> @@ -76,27 +81,15 @@ export default () => { role="region" aria-label="Example of useEuiOverflowScroll(y) region" css={css` - ${useEuiOverflowScroll('y', true)}; - height: 180px; + ${useEuiOverflowScroll('y', true)} + ${logicalCSS('height', '180px')} `} > } - snippet={ - `
- - - -
` - } + snippet="${useEuiOverflowScroll('y', true)}" + snippetLanguage="emotion" /> )} @@ -133,7 +126,7 @@ export default () => { snippetLanguage="scss" snippet={`.overflowShadowsY { @include euiYScrollWithShadows; - height: 180px; + block-size: 180px; }`} /> )} diff --git a/src-docs/src/views/tables/mobile/mobile_section.js b/src-docs/src/views/tables/mobile/mobile_section.js index d1c266acdbe..a098274f0d9 100644 --- a/src-docs/src/views/tables/mobile/mobile_section.js +++ b/src-docs/src/views/tables/mobile/mobile_section.js @@ -7,6 +7,7 @@ import { EuiCode, EuiCodeBlock } from '../../../../../src/components/code'; const source = require('!!raw-loader!./mobile'); import { EuiTableRowCellMobileOptionsShape } from '../props/props'; +/* eslint-disable local/css-logical-properties */ const exampleItem = `{ field: 'firstName', name: 'First Name', diff --git a/src-docs/src/views/theme/color/_color_js.tsx b/src-docs/src/views/theme/color/_color_js.tsx index c70456db6d9..8174aa930a0 100644 --- a/src-docs/src/views/theme/color/_color_js.tsx +++ b/src-docs/src/views/theme/color/_color_js.tsx @@ -19,6 +19,7 @@ import { EuiPanel, EuiSpacer, _EuiBackgroundColorOptions, + logicalCSS, } from '../../../../../src'; import { EuiThemeColors, ThemeRowType } from '../_props'; @@ -144,8 +145,8 @@ export const TextValuesJS = () => {
Aa diff --git a/src-docs/src/views/theme/customizing/_border.js b/src-docs/src/views/theme/customizing/_border.js index 15f1532b2ac..2e418ea28a1 100644 --- a/src-docs/src/views/theme/customizing/_border.js +++ b/src-docs/src/views/theme/customizing/_border.js @@ -8,6 +8,7 @@ import { EuiColorPickerSwatch, EuiPanel, EuiTitle, + logicalSizeCSS, } from '../../../../../src'; import { getPropsFromComponent } from '../../../services/props/get_props'; @@ -51,8 +52,7 @@ export default ({ onThemeUpdate }) => { const typeProps = getPropsFromComponent(EuiThemeBorderTypes); const style = css` - width: ${euiTheme.size.xl}; - height: ${euiTheme.size.xl}; + ${logicalSizeCSS(euiTheme.size.xl, euiTheme.size.xl)} border-radius: ${euiTheme.border.radius.small}; `; diff --git a/src-docs/src/views/theme/customizing/_colors.js b/src-docs/src/views/theme/customizing/_colors.js index d48aa72345f..d73468b02c1 100644 --- a/src-docs/src/views/theme/customizing/_colors.js +++ b/src-docs/src/views/theme/customizing/_colors.js @@ -12,6 +12,7 @@ import { EuiColorPickerSwatch, EuiCodeBlock, EuiTitle, + logicalCSS, } from '../../../../../src'; import { @@ -142,8 +143,8 @@ export default ({ onThemeUpdate }) => {