| 1 | import { defineRule, type ESTree } from "@oxlint/plugins"; |
| 2 | import * as Option from "effect/Option"; |
| 3 | |
| 4 | import { unwrapExpression } from "../utils.ts"; |
| 5 | |
| 6 | const SCROLLS_VERTICALLY = /(?:^|:)overflow-(?:y-)?auto$/u; |
| 7 | // Either utility keeps the content box the same width whether or not the scrollbar shows. Both |
| 8 | // exemptions must be unprefixed: a variant such as lg: leaves every other state unprotected. |
| 9 | const RESERVES_GUTTER = |
| 10 | /^(?:scrollbar-gutter-(?:stable|both)|\[scrollbar-gutter:stable(?:_both-edges)?\])$/u; |
| 11 | const HIDES_SCROLLBAR = /^\[scrollbar-width:none\]$/u; |
| 12 | const CENTERED_BY_MARGIN = /(?:^|:)mx?-auto$/u; |
| 13 | const COLUMN = /(?:^|:)flex-col(?:-reverse)?$/u; |
| 14 | const ALWAYS_COLUMN = /^flex-col(?:-reverse)?$/u; |
| 15 | const ROW = /(?:^|:)flex-row(?:-reverse)?$/u; |
| 16 | const CENTERS_COLUMN_ITEMS = /(?:^|:)(?:items-center(?:-safe)?|place-items-center)$/u; |
| 17 | const CENTERS_ROW_ITEMS = |
| 18 | /(?:^|:)(?:justify-center(?:-safe)?|\[justify-content:safe_center\]|place-items-center|place-content-center)$/u; |
| 19 | |
| 20 | interface ClassNames { |
| 21 | /** Classes that apply in at least one render. */ |
| 22 | readonly possible: Set<string>; |
| 23 | /** Classes that apply in every render, outside any conditional branch. */ |
| 24 | readonly always: Set<string>; |
| 25 | } |
| 26 | |
| 27 | /** |
| 28 | * The class names a className value can produce: string literals, template text, and the |
| 29 | * branches and arguments of conditionals and cn()-style calls. Conditions themselves are skipped. |
| 30 | */ |
| 31 | function collectClassNames(node: unknown, classNames: ClassNames, always: boolean) { |
| 32 | const expression = unwrapExpression(node); |
| 33 | if (Option.isNone(expression)) return; |
| 34 | const value = expression.value; |
| 35 | |
| 36 | const addAll = (text: string | null) => { |
| 37 | for (const className of text?.split(/\s+/u) ?? []) { |
| 38 | if (!className) continue; |
| 39 | classNames.possible.add(className); |
| 40 | if (always) classNames.always.add(className); |
| 41 | } |
| 42 | }; |
| 43 | |
| 44 | switch (value.type) { |
| 45 | case "Literal": |
| 46 | if (typeof value.value === "string") addAll(value.value); |
| 47 | return; |
| 48 | case "TemplateLiteral": |
| 49 | value.quasis.forEach((quasi, index) => { |
| 50 | // A token touching an interpolation is only part of a class name, such as `${prefix}x`. |
| 51 | const tokens = (quasi.value.cooked ?? "").split(/\s+/u); |
| 52 | if (index > 0) tokens.shift(); |
| 53 | if (!quasi.tail) tokens.pop(); |
| 54 | addAll(tokens.join(" ")); |
| 55 | }); |
| 56 | for (const nested of value.expressions) collectClassNames(nested, classNames, always); |
| 57 | return; |
| 58 | case "JSXExpressionContainer": |
| 59 | collectClassNames(value.expression, classNames, always); |
| 60 | return; |
| 61 | case "ConditionalExpression": |
| 62 | collectClassNames(value.consequent, classNames, false); |
| 63 | collectClassNames(value.alternate, classNames, false); |
| 64 | return; |
| 65 | case "LogicalExpression": |
| 66 | collectClassNames(value.left, classNames, false); |
| 67 | collectClassNames(value.right, classNames, false); |
| 68 | return; |
| 69 | case "ArrayExpression": |
| 70 | for (const element of value.elements) collectClassNames(element, classNames, always); |
| 71 | return; |
| 72 | case "CallExpression": |
| 73 | for (const argument of value.arguments) collectClassNames(argument, classNames, always); |
| 74 | return; |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | function classNamesOf(element: ESTree.JSXElement): ClassNames { |
| 79 | const classNames: ClassNames = { possible: new Set(), always: new Set() }; |
| 80 | for (const attribute of element.openingElement.attributes) { |
| 81 | if ( |
| 82 | attribute.type === "JSXAttribute" && |
| 83 | attribute.name.type === "JSXIdentifier" && |
| 84 | attribute.name.name === "className" |
| 85 | ) { |
| 86 | collectClassNames(attribute.value, classNames, true); |
| 87 | } |
| 88 | } |
| 89 | return classNames; |
| 90 | } |
| 91 | |
| 92 | const hasClass = (classNames: ReadonlySet<string>, pattern: RegExp) => |
| 93 | [...classNames].some((className) => pattern.test(className)); |
| 94 | |
| 95 | /** |
| 96 | * Reports a native vertical scroller that centers its content without reserving the scrollbar |
| 97 | * lane. A classic scrollbar narrows the scroller when it appears, so centered content jumps by |
| 98 | * half its width whenever the content grows past the fold. |
| 99 | */ |
| 100 | export default defineRule({ |
| 101 | meta: { |
| 102 | type: "problem", |
| 103 | docs: { |
| 104 | description: |
| 105 | "Require scrollbar-gutter-both on overflow-auto scrollers that center their content, so it does not shift when the scrollbar appears.", |
| 106 | }, |
| 107 | }, |
| 108 | create(context) { |
| 109 | const reported = new Set<ESTree.JSXElement>(); |
| 110 | |
| 111 | const report = (scroller: ESTree.JSXElement, { always }: ClassNames) => { |
| 112 | if (reported.has(scroller)) return; |
| 113 | if (hasClass(always, RESERVES_GUTTER) || hasClass(always, HIDES_SCROLLBAR)) return; |
| 114 | reported.add(scroller); |
| 115 | context.report({ |
| 116 | node: scroller.openingElement, |
| 117 | message: |
| 118 | "This scroller centers its content, which shifts sideways when the scrollbar appears. Add scrollbar-gutter-both.", |
| 119 | }); |
| 120 | }; |
| 121 | |
| 122 | return { |
| 123 | JSXElement(node) { |
| 124 | const classNames = classNamesOf(node); |
| 125 | const { possible } = classNames; |
| 126 | if (possible.size === 0) return; |
| 127 | |
| 128 | if (hasClass(possible, SCROLLS_VERTICALLY)) { |
| 129 | // A responsive or conditional direction can render as either axis, so check both. |
| 130 | const centersColumn = |
| 131 | hasClass(possible, COLUMN) && hasClass(possible, CENTERS_COLUMN_ITEMS); |
| 132 | const centersRow = |
| 133 | (!hasClass(classNames.always, ALWAYS_COLUMN) || hasClass(possible, ROW)) && |
| 134 | hasClass(possible, CENTERS_ROW_ITEMS); |
| 135 | if (centersColumn || centersRow) report(node, classNames); |
| 136 | } |
| 137 | |
| 138 | if (!hasClass(possible, CENTERED_BY_MARGIN)) return; |
| 139 | // The nearest enclosing scroller is the one whose scrollbar moves this element. |
| 140 | for (let ancestor: ESTree.Node | null = node.parent; ancestor; ancestor = ancestor.parent) { |
| 141 | // An element passed as a prop renders wherever that component puts it. |
| 142 | if (ancestor.type === "JSXAttribute") return; |
| 143 | if (ancestor.type !== "JSXElement") continue; |
| 144 | const ancestorClassNames = classNamesOf(ancestor); |
| 145 | if (!hasClass(ancestorClassNames.possible, SCROLLS_VERTICALLY)) continue; |
| 146 | report(ancestor, ancestorClassNames); |
| 147 | return; |
| 148 | } |
| 149 | }, |
| 150 | }; |
| 151 | }, |
| 152 | }); |