Skip to content

Commit e3593e6

Browse files
committed
chore(lint): flag menus and popovers that end a spaced list
While a Base UI menu or popover is open it inserts focus guards after its trigger, so a trigger that ends a space-*/divide-* container picks up that spacing and pushes the content below. Flag the pattern in web code.
1 parent bbd98a5 commit e3593e6

4 files changed

Lines changed: 364 additions & 0 deletions

File tree

‎oxlint-plugin-t3code/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-te
88
import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts";
99
import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
1010
import noOutsetStateIndicators from "./rules/no-outset-state-indicators.ts";
11+
import noPopupLastInSpacedContainer from "./rules/no-popup-last-in-spaced-container.ts";
1112
import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts";
1213
import noRawResizeObserver from "./rules/no-raw-resize-observer.ts";
1314
import noTestInLoop from "./rules/no-test-in-loop.ts";
@@ -30,6 +31,7 @@ export default definePlugin({
3031
"no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches,
3132
"no-native-title-tooltip": noNativeTitleTooltip,
3233
"no-outset-state-indicators": noOutsetStateIndicators,
34+
"no-popup-last-in-spaced-container": noPopupLastInSpacedContainer,
3335
"no-raw-mcp-registration": noRawMcpRegistration,
3436
"no-raw-resize-observer": noRawResizeObserver,
3537
"no-test-in-loop": noTestInLoop,
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { assert, describe } from "@effect/vitest";
2+
3+
import { createOxlintRuleHarness } from "../test/utils.ts";
4+
5+
const rule = createOxlintRuleHarness("t3code/no-popup-last-in-spaced-container", {
6+
filename: "fixture.tsx",
7+
});
8+
9+
const imports = `
10+
import { Menu, MenuTrigger, MenuPopup } from "~/components/ui/menu";
11+
import { Popover } from "../ui/popover";
12+
import { Select } from "~/components/ui/select";
13+
`;
14+
15+
describe("t3code/no-popup-last-in-spaced-container", () => {
16+
rule.valid(
17+
"allows a popup followed by a sibling",
18+
`${imports}
19+
const el = (
20+
<div className="space-y-3">
21+
<Menu><MenuTrigger /><MenuPopup /></Menu>
22+
<p>After</p>
23+
</div>
24+
);`,
25+
);
26+
27+
rule.valid(
28+
"allows a popup last in a gap container",
29+
`${imports}
30+
const el = <div className="flex flex-col gap-3"><p>Before</p><Menu /></div>;`,
31+
);
32+
33+
rule.valid(
34+
"allows a popup wrapped in its own element",
35+
`${imports}
36+
const el = <div className="space-y-3"><p>Before</p><div><Menu /></div></div>;`,
37+
);
38+
39+
rule.valid(
40+
"ignores zero spacing",
41+
`${imports}
42+
const el = <div className="space-y-0"><p>Before</p><Menu /></div>;`,
43+
);
44+
45+
rule.valid(
46+
"ignores spacing that targets other elements",
47+
`${imports}
48+
const el = <div className="[&_ul]:space-y-2 *:divide-y [&>li]:space-x-1"><p>Before</p><Menu /></div>;`,
49+
);
50+
51+
rule.invalid(
52+
"reports spacing behind a variant that targets the element itself",
53+
`${imports}
54+
const el = <div className="[&:hover]:space-y-2 group-hover:space-y-1"><p>Before</p><Menu /></div>;`,
55+
);
56+
57+
rule.valid(
58+
"ignores same-named components that are not ui popups",
59+
`import { Menu } from "./AppMenu";
60+
const el = <div className="space-y-3"><p>Before</p><Menu /></div>;`,
61+
);
62+
63+
rule.valid(
64+
"allows a select, whose hidden input stays last",
65+
`${imports}
66+
const el = <div className="space-y-3"><p>Before</p><Select /></div>;`,
67+
);
68+
69+
rule.invalid(
70+
"reports a popup last in a space-y container",
71+
`${imports}
72+
const el = <div className="space-y-3"><p>Before</p><Menu><MenuTrigger /><MenuPopup /></Menu></div>;`,
73+
(output) => {
74+
assert.match(output, /<Menu> can be the last child of a `space-y-3` container/);
75+
},
76+
);
77+
78+
rule.invalid(
79+
"reports variant and divide utilities inside cn",
80+
`${imports}
81+
const el = <div className={cn("sm:divide-y", active && "ring")}><p>Before</p><Popover /></div>;`,
82+
(output) => {
83+
assert.match(output, /`sm:divide-y`/);
84+
},
85+
);
86+
87+
rule.invalid(
88+
"reports a popup that is last only when a later sibling renders nothing",
89+
`${imports}
90+
const el = <div className="space-y-2"><Popover />{error ? <p>{error}</p> : null}</div>;`,
91+
);
92+
93+
rule.invalid(
94+
"reports a local component that renders a popup",
95+
`${imports}
96+
function CommandPicker() {
97+
return <Menu><MenuTrigger /><MenuPopup /></Menu>;
98+
}
99+
const el = <div className="space-y-3"><ol /><CommandPicker /></div>;`,
100+
(output) => {
101+
assert.match(output, /<CommandPicker>/);
102+
},
103+
);
104+
105+
rule.invalid(
106+
"reports a popup passed last into a local spaced slot",
107+
`${imports}
108+
function Field({ label, children }) {
109+
return <div className="space-y-1.5"><label>{label}</label>{children}</div>;
110+
}
111+
const el = <Field label="Model"><Popover /></Field>;`,
112+
(output) => {
113+
assert.match(output, /`space-y-1.5`/);
114+
},
115+
);
116+
});
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { defineRule, type ESTree } from "@oxlint/plugins";
2+
3+
// Roots whose open popup inserts Base UI focus guards right after the trigger. Select and
4+
// Combobox are safe: their hidden input always renders after the trigger, so it stays last.
5+
const POPUP_MODULE = /(?:^|\/)ui\/(?:menu|popover)$/u;
6+
const POPUP_ROOTS = new Set(["Menu", "Popover"]);
7+
// Utilities that style every child except the last one.
8+
const LAST_CHILD_UTILITY = /^-?space-[xy]-(?!0$)|^divide-[xy](?:$|-(?!0$|reverse$))/u;
9+
10+
type JsxChild = ESTree.JSXElement["children"][number];
11+
12+
const isNode = (value: unknown): value is ESTree.Node =>
13+
typeof value === "object" && value !== null && "type" in value;
14+
15+
function walk(node: unknown, visit: (node: ESTree.Node) => void) {
16+
if (Array.isArray(node)) {
17+
for (const child of node) walk(child, visit);
18+
return;
19+
}
20+
if (!isNode(node)) return;
21+
visit(node);
22+
for (const [key, value] of Object.entries(node)) {
23+
if (key !== "parent") walk(value, visit);
24+
}
25+
}
26+
27+
const elementName = (node: ESTree.Node) =>
28+
node.type === "JSXElement" && node.openingElement.name.type === "JSXIdentifier"
29+
? node.openingElement.name.name
30+
: undefined;
31+
32+
/** Splits a class token into its variants and utility, ignoring colons inside brackets. */
33+
function parseClass(token: string) {
34+
const variants: string[] = [];
35+
let depth = 0;
36+
let start = 0;
37+
for (let index = 0; index < token.length; index++) {
38+
const character = token[index];
39+
if (character === "[" || character === "(") depth++;
40+
else if (character === "]" || character === ")") depth--;
41+
else if (character === ":" && depth === 0) {
42+
variants.push(token.slice(start, index));
43+
start = index + 1;
44+
}
45+
}
46+
return { variants, utility: token.slice(start).replace(/^!|!$/gu, "") };
47+
}
48+
49+
// `*:`, `**:`, and arbitrary variants like `[&_ul]:` or `[&>li]:` style other elements.
50+
const targetsOtherElements = (variant: string) =>
51+
variant === "*" || variant === "**" || /^\[.*&.*[\s_>+~]/u.test(variant);
52+
53+
const isLastChildUtility = (token: string) => {
54+
const { variants, utility } = parseClass(token);
55+
return LAST_CHILD_UTILITY.test(utility) && !variants.some(targetsOtherElements);
56+
};
57+
58+
/** The first last-child utility anywhere in the element's className. */
59+
function lastChildUtility(element: ESTree.JSXElement) {
60+
for (const attribute of element.openingElement.attributes) {
61+
if (attribute.type !== "JSXAttribute" || attribute.name.name !== "className") continue;
62+
const strings: string[] = [];
63+
walk(attribute.value, (node) => {
64+
if (node.type === "Literal" && typeof node.value === "string") strings.push(node.value);
65+
if (node.type === "TemplateElement") strings.push(node.value.cooked ?? node.value.raw);
66+
});
67+
return strings.join(" ").split(/\s+/u).find(isLastChildUtility);
68+
}
69+
return undefined;
70+
}
71+
72+
/** JSX an expression can render: both branches of a ternary, the right of `&&`, a map callback's result. */
73+
function renderedBy(expression: ESTree.Node | null | undefined): ESTree.Node[] {
74+
if (!expression) return [];
75+
switch (expression.type) {
76+
case "ParenthesizedExpression":
77+
return renderedBy(expression.expression);
78+
case "JSXElement":
79+
return [expression];
80+
case "JSXFragment":
81+
return possiblyLast(expression.children);
82+
case "ConditionalExpression":
83+
return [...renderedBy(expression.consequent), ...renderedBy(expression.alternate)];
84+
case "LogicalExpression":
85+
return renderedBy(expression.right);
86+
case "CallExpression": {
87+
const callback = expression.arguments[0];
88+
if (callback?.type !== "ArrowFunctionExpression" && callback?.type !== "FunctionExpression")
89+
return [];
90+
return callback.body ? returnedBy(callback.body) : [];
91+
}
92+
default:
93+
return [expression];
94+
}
95+
}
96+
97+
/** JSX a function body returns, ignoring nested functions. */
98+
function returnedBy(body: ESTree.Node): ESTree.Node[] {
99+
if (body.type !== "BlockStatement") return renderedBy(body);
100+
const found: ESTree.Node[] = [];
101+
const visit = (node: unknown) => {
102+
if (Array.isArray(node)) return node.forEach(visit);
103+
if (!isNode(node)) return;
104+
if (node.type === "ReturnStatement") return found.push(...renderedBy(node.argument));
105+
if (/Function/u.test(node.type)) return;
106+
for (const [key, value] of Object.entries(node)) if (key !== "parent") visit(value);
107+
};
108+
visit(body.body);
109+
return found;
110+
}
111+
112+
/** Whether a child can render nothing, letting an earlier sibling end up last. */
113+
function mayRenderNothing(child: JsxChild) {
114+
if (child.type !== "JSXExpressionContainer") return false;
115+
const { expression } = child;
116+
if (expression.type === "LogicalExpression" || expression.type === "CallExpression") return true;
117+
return (
118+
expression.type === "ConditionalExpression" &&
119+
[expression.consequent, expression.alternate].some(
120+
(branch) => branch.type === "Literal" && branch.value === null,
121+
)
122+
);
123+
}
124+
125+
/** Nodes that can end up as the last rendered child. */
126+
function possiblyLast(children: ReadonlyArray<JsxChild>): ESTree.Node[] {
127+
const meaningful = children.filter(
128+
(child) =>
129+
!(child.type === "JSXText" && child.value.trim() === "") &&
130+
!(child.type === "JSXExpressionContainer" && child.expression.type === "JSXEmptyExpression"),
131+
);
132+
const found: ESTree.Node[] = [];
133+
for (let index = meaningful.length - 1; index >= 0; index--) {
134+
const child = meaningful[index]!;
135+
found.push(
136+
...(child.type === "JSXExpressionContainer" ? renderedBy(child.expression) : [child]),
137+
);
138+
if (!mayRenderNothing(child)) break;
139+
}
140+
return found;
141+
}
142+
143+
const isChildrenSlot = (node: ESTree.Node) =>
144+
(node.type === "Identifier" && node.name === "children") ||
145+
(node.type === "MemberExpression" &&
146+
node.property.type === "Identifier" &&
147+
node.property.name === "children");
148+
149+
/** Components declared in this file, by name, with the function each one renders from. */
150+
function localComponents(program: ESTree.Program) {
151+
const components = new Map<string, ESTree.Node>();
152+
walk(program, (node) => {
153+
if (node.type === "FunctionDeclaration" && node.id && /^[A-Z]/u.test(node.id.name)) {
154+
components.set(node.id.name, node);
155+
}
156+
if (node.type === "VariableDeclarator" && node.id.type === "Identifier") {
157+
// Unwrap memo(...) and forwardRef(...).
158+
let init: ESTree.Node | null = node.init;
159+
while (init?.type === "CallExpression" && init.arguments[0]) init = init.arguments[0];
160+
if (
161+
/^[A-Z]/u.test(node.id.name) &&
162+
(init?.type === "ArrowFunctionExpression" || init?.type === "FunctionExpression")
163+
) {
164+
components.set(node.id.name, init);
165+
}
166+
}
167+
});
168+
return components;
169+
}
170+
171+
export default defineRule({
172+
meta: {
173+
type: "problem",
174+
docs: {
175+
description:
176+
"Disallow a popup root as the last child of a space-*/divide-* container; its focus guards shift the layout while it is open.",
177+
},
178+
},
179+
create(context) {
180+
return {
181+
Program(program) {
182+
const popups = new Set<string>();
183+
for (const statement of program.body) {
184+
if (statement.type !== "ImportDeclaration") continue;
185+
if (!POPUP_MODULE.test(statement.source.value)) continue;
186+
for (const specifier of statement.specifiers) {
187+
if (
188+
specifier.type === "ImportSpecifier" &&
189+
specifier.imported.type === "Identifier" &&
190+
POPUP_ROOTS.has(specifier.imported.name)
191+
) {
192+
popups.add(specifier.local.name);
193+
}
194+
}
195+
}
196+
if (popups.size === 0) return;
197+
198+
// Local components that render a popup root, or that place children last in a spaced container.
199+
const components = localComponents(program);
200+
const spacedSlots = new Map<string, string>();
201+
for (const [name, fn] of components) {
202+
walk(fn, (node) => {
203+
if (node.type !== "JSXElement" || spacedSlots.has(name)) return;
204+
const utility = lastChildUtility(node);
205+
if (utility && possiblyLast(node.children).some(isChildrenSlot)) {
206+
spacedSlots.set(name, utility);
207+
}
208+
});
209+
}
210+
let grew = true;
211+
while (grew) {
212+
grew = false;
213+
for (const [name, fn] of components) {
214+
if (popups.has(name) || !("body" in fn) || !isNode(fn.body)) continue;
215+
const roots = returnedBy(fn.body);
216+
if (roots.some((root) => popups.has(elementName(root) ?? ""))) {
217+
popups.add(name);
218+
grew = true;
219+
}
220+
}
221+
}
222+
223+
walk(program, (node) => {
224+
if (node.type !== "JSXElement") return;
225+
const name = elementName(node);
226+
const utility = (name && spacedSlots.get(name)) ?? lastChildUtility(node);
227+
if (!utility) return;
228+
for (const child of possiblyLast(node.children)) {
229+
const popup = elementName(child);
230+
if (!popup || !popups.has(popup)) continue;
231+
context.report({
232+
node: child,
233+
message: `<${popup}> can be the last child of a \`${utility}\` container. While its popup is open, Base UI inserts focus guards after the trigger, so \`${utility}\` starts spacing the trigger and the content below shifts. Use gap, or put the spacing on the siblings.`,
234+
});
235+
}
236+
});
237+
},
238+
};
239+
},
240+
});

‎vite.config.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,12 @@ export default defineConfig({
266266
files: ["apps/web/src/**"],
267267
rules: { "shadcn/no-unknown-classes": "error" },
268268
},
269+
{
270+
// An open popup inserts focus guards after its trigger, so a trigger that ends a
271+
// space-*/divide-* container starts getting spaced and pushes the content below.
272+
files: ["apps/web/src/**"],
273+
rules: { "t3code/no-popup-last-in-spaced-container": "error" },
274+
},
269275
{
270276
// State indicators paint inward; decorative rings keep their own geometry.
271277
files: ["apps/web/src/**"],

0 commit comments

Comments
 (0)