diff --git a/README.md b/README.md index 9a5fd39..be26d03 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ The plugin provides multiple rules that can be toggled on and off as needed. 3. [Custom Property Fallbacks](#custom-property-fallbacks) 4. [Flex Wrapping](#flex-wrapping) 5. [Scroll Chaining](#scroll-chaining) -6. [Vendor Prefix Grouping](#vendor-prefix-grouping) +6. [Scrollbar Gutter](#scrollbar-gutter) +7. [Vendor Prefix Grouping](#vendor-prefix-grouping) --- @@ -328,6 +329,66 @@ div { } ``` +### Scrollbar Gutter + +> [Read more about this pattern in Defensive CSS](https://defensivecss.dev/tip/scrollbar-gutter/) + +Imagine a container with only a small amount of content with no need to scroll. +The content would be aligned evenly within the boundaries of its container. Now, +if that container has more content added, and a scrollbar appears, that +scrollbar will cause a layout shift, forcing the content to reflow and jump. +This behavior can be jarring. + +To avoid layout shifting with variable content, enforce that a +`scrollbar-gutter` property is defined for any scrollable container. + +```json +{ + "rules": { + "plugin/use-defensive-css": [true, { "scrollbar-gutter": true }] + } +} +``` + +#### ✅ Passing Examples + +```css +div { + overflow-x: auto; + scrollbar-gutter: auto; +} + +div { + overflow: hidden scroll; + scrollbar-gutter: stable; +} + +div { + overflow: hidden; /* No overscroll-behavior is needed in the case of hidden */ +} + +div { + overflow-block: auto; + scrollbar-gutter: stable both-edges; +} +``` + +#### ❌ Failing Examples + +```css +div { + overflow-x: auto; +} + +div { + overflow: hidden scroll; +} + +div { + overflow-block: auto; +} +``` + ### Vendor Prefix Grouping > [Read more about this pattern in Defensive CSS](https://defensivecss.dev/tip/grouping-selectors/) diff --git a/src/rules/use-defensive-css/base.js b/src/rules/use-defensive-css/base.js index 18d06c3..6ec0359 100644 --- a/src/rules/use-defensive-css/base.js +++ b/src/rules/use-defensive-css/base.js @@ -17,6 +17,9 @@ const ruleMessages = stylelint.utils.ruleMessages(ruleName, { flexWrapping() { return 'Flex rows must have a `flex-wrap` value defined.`'; }, + scrollbarGutter() { + return `Containers with an auto or scroll 'overflow' must also have a 'scrollbar-gutter' property defined.`; + }, scrollChaining() { return `Containers with an auto or scroll 'overflow' must also have an 'overscroll-behavior' property defined.`; }, diff --git a/src/rules/use-defensive-css/index.js b/src/rules/use-defensive-css/index.js index 3ea4d68..fe34dbf 100644 --- a/src/rules/use-defensive-css/index.js +++ b/src/rules/use-defensive-css/index.js @@ -20,6 +20,11 @@ const defaultFlexWrappingProps = { isMissingFlexWrap: true, nodeToReport: undefined, }; +const defaultScrollbarGutterProps = { + hasOverflow: false, + hasScrollbarGutter: false, + nodeToReport: undefined, +}; const defaultScrollChainingProps = { hasOverflow: false, hasOverscrollBehavior: false, @@ -28,10 +33,19 @@ const defaultScrollChainingProps = { let backgroundRepeatProps = { ...defaultBackgroundRepeatProps }; let flexWrappingProps = { ...defaultFlexWrappingProps }; +let scrollbarGutterProps = { ...defaultScrollbarGutterProps }; let scrollChainingProps = { ...defaultScrollChainingProps }; let isLastStyleDeclaration = false; let isWrappedInHoverAtRule = false; +const overflowProperties = [ + 'overflow', + 'overflow-x', + 'overflow-y', + 'overflow-inline', + 'overflow-block', +]; + function traverseParentRules(parent) { if (parent.parent.type === 'root') { return; @@ -184,15 +198,39 @@ const ruleFunction = (_, options) => { } } + /* SCROLLBAR GUTTER */ + if (options?.['scrollbar-gutter']) { + if ( + overflowProperties.includes(decl.prop) && + (decl.value.includes('auto') || decl.value.includes('scroll')) + ) { + scrollbarGutterProps.hasOverflow = true; + scrollbarGutterProps.nodeToReport = decl; + } + + if (decl.prop.includes('scrollbar-gutter')) { + scrollbarGutterProps.hasScrollbarGutter = true; + } + + if (isLastStyleDeclaration) { + if ( + scrollbarGutterProps.hasOverflow && + !scrollbarGutterProps.hasScrollbarGutter + ) { + stylelint.utils.report({ + message: ruleMessages.scrollbarGutter(), + node: scrollbarGutterProps.nodeToReport, + result, + ruleName, + }); + } + + scrollbarGutterProps = { ...defaultScrollbarGutterProps }; + } + } + /* SCROLL CHAINING */ if (options?.['scroll-chaining']) { - const overflowProperties = [ - 'overflow', - 'overflow-x', - 'overflow-y', - 'overflow-inline', - 'overflow-block', - ]; if ( overflowProperties.includes(decl.prop) && (decl.value.includes('auto') || decl.value.includes('scroll')) diff --git a/src/rules/use-defensive-css/index.test.js b/src/rules/use-defensive-css/index.test.js index 6736482..12cb86b 100644 --- a/src/rules/use-defensive-css/index.test.js +++ b/src/rules/use-defensive-css/index.test.js @@ -304,6 +304,93 @@ testRule({ ], }); +/* eslint-disable-next-line no-undef */ +testRule({ + ruleName, + config: [true, { 'scrollbar-gutter': true }], + plugins: ['./index.js'], + accept: [ + { + code: `div { overflow: auto; scrollbar-gutter: auto; }`, + description: 'A container with shorthand overflow auto property.', + }, + { + code: `div { overflow: hidden; }`, + description: 'A container with shorthand overflow hidden property.', + }, + { + code: `div { overflow: scroll; scrollbar-gutter: stable; }`, + description: 'A container with shorthand overflow scroll property.', + }, + + { + code: `div { overflow: auto hidden; scrollbar-gutter: stable both-edges; }`, + description: 'A container with shorthand overflow auto hidden property.', + }, + { + code: `div { overflow-x: hidden; }`, + description: 'A container with overflow-x hidden property.', + }, + { + code: `div { overflow-x: auto; scrollbar-gutter: stable; }`, + description: 'A container with overflow-x auto property.', + }, + { + code: `div { overflow-x: auto; overflow-y: scroll; scrollbar-gutter: stable; }`, + description: + 'A container with overflow-x auto and overflow-y scroll property.', + }, + { + code: `div { overflow-block: auto; scrollbar-gutter: stable; }`, + description: 'A container with overflow-block auto property.', + }, + { + code: `div { overflow-inline: hidden; }`, + description: 'A container with overflow-inline hidden property.', + }, + { + code: `div { overflow-anchor: auto; }`, + description: + 'A container with overflow-anchor property which should be ignored.', + }, + ], + + reject: [ + { + code: `div { overflow: auto; }`, + description: 'A container with shorthand overflow auto property.', + message: messages.scrollbarGutter(), + }, + { + code: `div { overflow: auto hidden; }`, + description: 'A container with shorthand overflow auto hidden property.', + message: messages.scrollbarGutter(), + }, + { + code: `div { overflow-x: auto; }`, + description: 'A container with overflow-x auto property.', + message: messages.scrollbarGutter(), + }, + { + code: `div { overflow-y: scroll; }`, + description: 'A container with overflow-y scroll property.', + message: messages.scrollbarGutter(), + }, + { + code: `div { overflow-y: scroll; overflow-x: auto; }`, + description: + 'A container with overflow-y scroll and overflow-x auto property.', + message: messages.scrollbarGutter(), + }, + { + code: `div { overflow-block: scroll; overflow-inline: auto; }`, + description: + 'A container with overflow-block scroll and overflow-inline auto property.', + message: messages.scrollbarGutter(), + }, + ], +}); + /* eslint-disable-next-line no-undef */ testRule({ ruleName,