| 1 | /* oxlint-disable t3code/no-unscoped-has -- the fixtures are invalid on purpose */ |
| 2 | import { assert, describe } from "@effect/vitest"; |
| 3 | |
| 4 | import { createOxlintRuleHarness } from "../test/utils.ts"; |
| 5 | |
| 6 | const rule = createOxlintRuleHarness("t3code/no-unscoped-has", { |
| 7 | filename: "fixture.tsx", |
| 8 | }); |
| 9 | |
| 10 | describe("t3code/no-unscoped-has", () => { |
| 11 | rule.valid( |
| 12 | "allows :has() on the element itself", |
| 13 | `const className = "[&:has([data-slot=icon])]:ps-2";`, |
| 14 | ); |
| 15 | |
| 16 | rule.valid( |
| 17 | "allows :has() anchored to an attribute", |
| 18 | `const className = "[&+[data-chat-composer-form]:has(>[data-slot=banner])]:mt-0";`, |
| 19 | ); |
| 20 | |
| 21 | rule.valid( |
| 22 | "allows :has() inside :not() on the element itself", |
| 23 | `const className = "[&:not(:has(+[data-slot=footer]))]:rounded-b-2xl";`, |
| 24 | ); |
| 25 | |
| 26 | rule.valid( |
| 27 | "allows built-in has-* variants", |
| 28 | `const className = "has-[>[data-slot=icon]]:ps-2 group-has-[:checked]:opacity-100";`, |
| 29 | ); |
| 30 | |
| 31 | rule.valid( |
| 32 | "allows a sibling selector without :has()", |
| 33 | `const className = "[&+*_[data-chat-composer-form]>[data-slot=attachment]]:before:rounded-none";`, |
| 34 | ); |
| 35 | |
| 36 | rule.valid("ignores prose mentioning :has()", `const note = "uses :has( for styling";`); |
| 37 | |
| 38 | rule.valid( |
| 39 | "ignores :has() inside an arbitrary value", |
| 40 | `const className = "before:content-[':has(foo)']";`, |
| 41 | ); |
| 42 | |
| 43 | rule.valid( |
| 44 | "allows a negated :has() on the element itself", |
| 45 | `const className = "[&:not(.collapsed):not(:has(>[data-slot=icon]))]:ps-2";`, |
| 46 | ); |
| 47 | |
| 48 | rule.valid( |
| 49 | "allows :has() on a group or peer element", |
| 50 | `const className = "group-[:has(input)]:p-2 peer-[:has(input)]:p-2 group-[&:has(input)]/row:p-2";`, |
| 51 | ); |
| 52 | |
| 53 | rule.valid( |
| 54 | "allows a selector list whose own branch is anchored", |
| 55 | `const className = "[:is(.a,.b):has(x)_&]:p-2 [&:not(.a,:has(x))]:p-2";`, |
| 56 | ); |
| 57 | |
| 58 | rule.valid( |
| 59 | "ignores :has() text in quoted attribute values", |
| 60 | `const className = "data-[foo='_:has(x)']:p-2 [&_[data-query='_:has(foo)']]:p-2";`, |
| 61 | ); |
| 62 | |
| 63 | rule.valid( |
| 64 | "allows a selector without & on the element itself", |
| 65 | `const className = "[:has(>input)]:p-2 not-[:has(>[data-slot=icon])]:ps-2";`, |
| 66 | ); |
| 67 | |
| 68 | rule.invalid( |
| 69 | "reports a sibling :has() with nothing anchoring it", |
| 70 | `const className = "[&+:has([data-chat-composer-form])_[data-chat-composer-form]]:before:rounded-none";`, |
| 71 | (output) => { |
| 72 | assert.match(output, /Anchor the :has\(\)/); |
| 73 | }, |
| 74 | ); |
| 75 | |
| 76 | rule.invalid( |
| 77 | "reports a descendant :has() with nothing anchoring it", |
| 78 | `const className = cn("p-2", "[&_:has(>input)]:gap-1");`, |
| 79 | ); |
| 80 | |
| 81 | rule.invalid( |
| 82 | "reports a universal :has() ancestor", |
| 83 | "const className = `flex [*:has([data-open])_&]:hidden`;", |
| 84 | ); |
| 85 | |
| 86 | rule.invalid( |
| 87 | "reports :has() whose only anchor is negated", |
| 88 | `const className = "[*:not(.safe):has([data-open])_&]:hidden";`, |
| 89 | ); |
| 90 | |
| 91 | rule.invalid("reports uppercase :HAS()", `const className = "[*:HAS([data-open])_&]:hidden";`); |
| 92 | |
| 93 | rule.invalid( |
| 94 | "reports a selector-list branch borrowing another branch's anchor", |
| 95 | `const className = "[.safe,:has(input)_&]:p-2";`, |
| 96 | ); |
| 97 | |
| 98 | rule.invalid( |
| 99 | "reports an unanchored branch inside :is()", |
| 100 | `const className = "[&_:is(.safe,:has(input))]:p-2";`, |
| 101 | ); |
| 102 | |
| 103 | rule.invalid( |
| 104 | "reports an unanchored :has() inside a named group variant", |
| 105 | `const className = "group-[&_:has(x)]/row:p-2";`, |
| 106 | ); |
| 107 | |
| 108 | rule.invalid( |
| 109 | "reports a :has() on an ancestor of a selector without &", |
| 110 | `const className = "[:has(x)_.foo]:p-2";`, |
| 111 | ); |
| 112 | |
| 113 | rule.invalid( |
| 114 | "reports a :has() on an ancestor of a group", |
| 115 | `const className = "group-[:has(x)_.y]:p-2";`, |
| 116 | ); |
| 117 | |
| 118 | rule.invalid("reports an in-* ancestor :has()", `const className = "in-[:has(x)]:p-2";`); |
| 119 | |
| 120 | rule.invalid( |
| 121 | "reports :has() anchored to the document root", |
| 122 | `const className = "[body:has([data-dialog-open])_&]:overflow-hidden";`, |
| 123 | ); |
| 124 | }); |
| 125 | |
| 126 | const indicators = createOxlintRuleHarness("t3code/no-outset-state-indicators", { |
| 127 | filename: "fixture.tsx", |
| 128 | }); |
| 129 | |
| 130 | describe("t3code/no-outset-state-indicators", () => { |
| 131 | describe.each( |
| 132 | Object.entries({ |
| 133 | "inset state rings": `const el = <button className="focus-visible:ring-2 focus-visible:ring-inset ring-1 ring-border" />;`, |
| 134 | "an unconditional inset": `const el = <button className="ring-inset data-selected:ring-2" />;`, |
| 135 | "an inset in a sibling composer argument": `const value = cn(active && "dark:focus-visible:ring-2", "focus-visible:ring-inset");`, |
| 136 | "an inset in a nested conditional composer": `const value = cn(enabled && cn("focus-visible:ring-2", "focus-visible:ring-inset"));`, |
| 137 | "outlines covered by the inward default": `const el = <button className="focus-visible:outline-2 focus-visible:outline-ring" />;`, |
| 138 | "wider outlines with a matching inward offset": `const el = <button className="focus-visible:outline-3 focus-visible:-outline-offset-3" />;`, |
| 139 | "arbitrary pixel outlines with a matching inward offset": `const el = <button className="focus-visible:outline-[3px] focus-visible:-outline-offset-3" />;`, |
| 140 | "arbitrary outline colors": `const el = <button className="focus-visible:outline-[#243c5a]" />;`, |
| 141 | "decorative rings without a state": `const el = <img className="ring-2 ring-background" />;`, |
| 142 | prose: `const note = "the focus ring stays visible";`, |
| 143 | }), |
| 144 | )("valid: %s", (name, source) => { |
| 145 | indicators.valid(name, source); |
| 146 | }); |
| 147 | |
| 148 | describe.each( |
| 149 | Object.entries({ |
| 150 | "state rings without an inset": `const el = <button className="focus-visible:ring-2 focus-visible:ring-ring" />;`, |
| 151 | "an inset from another state": `const el = <button className="focus:ring-inset focus-visible:ring-2" />;`, |
| 152 | "an inset from another conditional branch": `const value = cn(active ? "focus:ring-2" : "ring-inset");`, |
| 153 | "an inset passed to another function": `const value = cn("focus-visible:ring-2", label("ring-inset"));`, |
| 154 | "arbitrary state ring widths": `const value = cn("has-[:focus-visible]:ring-[3px]");`, |
| 155 | "positive outline offsets": `const el = <button className="focus-visible:outline-2 focus-visible:outline-offset-2" />;`, |
| 156 | "arbitrary outline offsets": `const el = <button className="focus-visible:[outline-offset:2px]" />;`, |
| 157 | "wide outlines without an inward offset": |
| 158 | "const className = `focus-visible:outline-4 ${layout}`;", |
| 159 | }), |
| 160 | )("invalid: %s", (name, source) => { |
| 161 | indicators.invalid(name, source); |
| 162 | }); |
| 163 | }); |