diff --git a/.changeset/message-polish.md b/.changeset/message-polish.md new file mode 100644 index 00000000..29512452 --- /dev/null +++ b/.changeset/message-polish.md @@ -0,0 +1,9 @@ +--- +'onsystem': patch +--- + +An unknown component that looks like a part of a family the design system has, but is like none of its parts, is no longer taken for the part spelled closest. `DialogContent` in a design system on React Aria, whose `Dialog` is the content, got "Did you mean ?"; it now gets "Did you mean , or one of its parts: DialogTrigger, DialogHeader, DialogFooter, DialogTitle?", with no rename. So do `DropdownMenuContent` and `ContextMenuContent` there, which were taken for the submenu's `DropdownMenuSubContent` and `ContextMenuSubContent`. A misspelled part (`DialogTitel`, `AvatarImg`) is still taken for that part, while a part unlike the one spelled closest no longer is: `SidebarDivider` was taken for `SidebarProvider`, `CardToggle` for `CardTitle` and `CarouselProvider` for `CarouselPrevious`. + +`asChild` on a React Aria trigger (``), which renders no element of its own, now says that the trigger takes the element that opens it as a child: a button, a menu item for a submenu's trigger, and the area you right-click for a context menu's. On a React Aria component, whose `render` is a function, `asChild`, `as`, `component` and `href` no longer get Base UI's advice (`render={}`), which React Aria's `render` does not take: they say to use a link component for a link, or to pass `href` to a component that takes it (`SidebarMenuButton`, `BreadcrumbLink`). A `render` function of the design system's own keeps the old advice. + +The ESLint plugin no longer waits two minutes on a file when its loader worker stopped before it answered, as `process.exit()` in an `onsystem.config.mjs`, an uncaught error or an unhandled rejection makes it: every file of the run, a file sent to it while it was busy too, fails at once with "the design system's loader stopped before it answered", and why. diff --git a/docs/rules.md b/docs/rules.md index 6760a3cc..64fcd11e 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -2,16 +2,16 @@ Eight rules, the same in `check_ui`, `onsystem check` and the Claude Code hook. Every finding has a rule id, a location and a message that says what to write instead; most carry a fix as text edits. When a fix renames a tag to a component the file does not import yet (` } +export function CarouselNext(props: { children?: React.ReactNode }) { return }`, + }, + { nodeModules: true }, + ), + ); + const code = `import { Card, CardTitle } from "@/components/ui/card" +import { Carousel, CarouselPrevious } from "@/components/ui/carousel" +export default () => ( + <> + HiBody + + +)`; + const found = findings(families, code, 'no-unknown-component'); + expect(found.map((d) => [d.message, d.fix?.[0]?.text])).toEqual([ + [ + 'Unknown component . Did you mean , or one of its parts: CardHeader, CardTitle, CardContent?', + undefined, + ], + [ + 'Unknown component . Did you mean (import { CardContent } from "@/components/ui/card")?', + undefined, + ], + [ + 'Unknown component . Did you mean , or one of its parts: CarouselContent, CarouselItem, CarouselPrevious, CarouselNext?', + undefined, + ], + ]); + }); + + it('does not take a component of its own for a part of a component without parts', () => { + const code = `import { Button, ButtonGroup } from "@/components/ui/button" +export default () => `; + expect(findings(aria, code, 'no-unknown-component').map((d) => d.message)).toEqual([ + '"ButtonGroup" is not a design-system component (imported from "@/components/ui/button"). Use search_components to find an existing one.', + ]); + }); +}); + +describe('composition in React Aria Components', () => { + let rac: DesignSystem; + beforeAll(async () => { + rac = await loadOnce(RAC_ROOT); + }); + + it('says a trigger takes the element that opens it as a child, for asChild', () => { + const code = `import { Button } from "@/components/ui/button" +import { DialogTrigger } from "@/components/ui/dialog" +export default () => `; + const [d] = findings(rac, code, 'no-unknown-prop'); + expect(d?.message).toBe( + ' has no prop "asChild". It takes the element that opens it as a child: ….', + ); + expect(d?.suggestion).toBeUndefined(); + expect(d?.fix).toBeUndefined(); + }); + + it("names a menu item for a submenu's trigger, and no element for a context menu's", () => { + const code = `import { Button } from "@/components/ui/button" +import { ContextMenuTrigger, Menu, MenuItem, MenuTrigger, SubmenuTrigger } from "@/components/ui/menu" +export default () => ( + <> + Edit + ShareMail +
Right-click here
Copy
+ +)`; + expect(findings(rac, code, 'no-unknown-prop').map((d) => d.message)).toEqual([ + ' has no prop "asChild". It takes the element that opens it as a child: ….', + ' has no prop "asChild". It takes the element that opens it as a child: …….', + ' has no prop "asChild". It takes the area that opens the menu on a right-click as a child: ….', + ]); + }); + + it("does not point at Base UI's render element, which React Aria's render function is not", () => { + const code = `import { Button } from "@/components/ui/button" +import Link from "next/link" +export default () => ( + <> + + + +)`; + expect(findings(rac, code, 'no-unknown-prop').map((d) => [d.message, d.suggestion])).toEqual([ + [ + '
`; + expect(findings(own, code, 'no-unknown-prop').map((d) => [d.message, d.suggestion])).toEqual([ + [' has no prop "asChild".', undefined], + ]); + }); +}); + +describe('a render function of the design system’s own', () => { + it('keeps the render advice, since it may return a link', async () => { + // shadcn/ui's attachment on React Aria: its trigger is a button with a render function of + // its own, typed as one that takes props, as React Aria's spelled out is. + const own = await load( + fixture( + { + 'tsconfig.json': TSCONFIG, + 'components/ui/attachment.tsx': `import * as React from "react" +export function AttachmentTrigger({ render, ...props }: React.ComponentProps<"button"> & { render?: (props: React.HTMLAttributes) => React.ReactNode }) { + return render ? <>{render(props)} :