diff --git a/apps/examples/calcom/app/(dashboard)/booking/bookings-list.tsx b/apps/examples/calcom/app/(dashboard)/booking/bookings-list.tsx index 32e0eb458..fdeaa9250 100644 --- a/apps/examples/calcom/app/(dashboard)/booking/bookings-list.tsx +++ b/apps/examples/calcom/app/(dashboard)/booking/bookings-list.tsx @@ -1,7 +1,7 @@ "use client"; import { Badge } from "@coss/ui/components/badge"; -import { Button } from "@coss/ui/components/button"; +import { Button, buttonVariants } from "@coss/ui/components/button"; import { Card, CardFrame, @@ -400,16 +400,19 @@ function BookingListItem({ /> {showJoinLink && ( - + )} diff --git a/apps/examples/calcom/app/(dashboard)/booking/past/bookings-list.tsx b/apps/examples/calcom/app/(dashboard)/booking/past/bookings-list.tsx index 394ebe2f6..4109dc01b 100644 --- a/apps/examples/calcom/app/(dashboard)/booking/past/bookings-list.tsx +++ b/apps/examples/calcom/app/(dashboard)/booking/past/bookings-list.tsx @@ -1,7 +1,7 @@ "use client"; import { Badge } from "@coss/ui/components/badge"; -import { Button } from "@coss/ui/components/button"; +import { Button, buttonVariants } from "@coss/ui/components/button"; import { Card, CardFrame, @@ -201,15 +201,17 @@ export function BookingsList() {

{locationLabel && ( - + )} diff --git a/apps/examples/calcom/app/(settings)/settings/developer/webhooks/new/page.tsx b/apps/examples/calcom/app/(settings)/settings/developer/webhooks/new/page.tsx index cff31dada..335113821 100644 --- a/apps/examples/calcom/app/(settings)/settings/developer/webhooks/new/page.tsx +++ b/apps/examples/calcom/app/(settings)/settings/developer/webhooks/new/page.tsx @@ -1,4 +1,4 @@ -import { Button } from "@coss/ui/components/button"; +import { Button, buttonVariants } from "@coss/ui/components/button"; import { Card, CardFrame, @@ -36,12 +36,12 @@ export default function NewWebhookPage() { - + diff --git a/apps/examples/calcom/app/(settings)/settings/organizations/attributes/[id]/edit/page.tsx b/apps/examples/calcom/app/(settings)/settings/organizations/attributes/[id]/edit/page.tsx index 14c016631..f80e1385e 100644 --- a/apps/examples/calcom/app/(settings)/settings/organizations/attributes/[id]/edit/page.tsx +++ b/apps/examples/calcom/app/(settings)/settings/organizations/attributes/[id]/edit/page.tsx @@ -1,4 +1,4 @@ -import { Button } from "@coss/ui/components/button"; +import { Button, buttonVariants } from "@coss/ui/components/button"; import { Card, CardFrame, @@ -25,14 +25,13 @@ export default async function EditAttributePage({ <>
- + Update a custom attribute for your team members. diff --git a/apps/examples/calcom/app/(settings)/settings/organizations/attributes/attributes-section.tsx b/apps/examples/calcom/app/(settings)/settings/organizations/attributes/attributes-section.tsx index ef7002307..2777da699 100644 --- a/apps/examples/calcom/app/(settings)/settings/organizations/attributes/attributes-section.tsx +++ b/apps/examples/calcom/app/(settings)/settings/organizations/attributes/attributes-section.tsx @@ -9,7 +9,7 @@ import { AlertDialogPopup, AlertDialogTitle, } from "@coss/ui/components/alert-dialog"; -import { Button } from "@coss/ui/components/button"; +import { Button, buttonVariants } from "@coss/ui/components/button"; import { Card, CardFrame, @@ -127,12 +127,13 @@ export function AttributesSection({ - + ); @@ -144,14 +145,13 @@ export function AttributesSection({ Custom - + diff --git a/apps/examples/calcom/app/(settings)/settings/organizations/attributes/new/page.tsx b/apps/examples/calcom/app/(settings)/settings/organizations/attributes/new/page.tsx index d44c5462d..884fc8433 100644 --- a/apps/examples/calcom/app/(settings)/settings/organizations/attributes/new/page.tsx +++ b/apps/examples/calcom/app/(settings)/settings/organizations/attributes/new/page.tsx @@ -1,4 +1,4 @@ -import { Button } from "@coss/ui/components/button"; +import { Button, buttonVariants } from "@coss/ui/components/button"; import { Card, CardFrame, @@ -19,14 +19,13 @@ export default function NewAttributePage() { <>
- + Create a custom attribute for your team members. diff --git a/apps/examples/calcom/components/app/app-command.tsx b/apps/examples/calcom/components/app/app-command.tsx index dc229304d..1595d5fe2 100644 --- a/apps/examples/calcom/components/app/app-command.tsx +++ b/apps/examples/calcom/components/app/app-command.tsx @@ -1,7 +1,7 @@ "use client"; import { useAutocompleteFilter } from "@coss/ui/components/autocomplete"; -import { Button } from "@coss/ui/components/button"; +import { Button, buttonVariants } from "@coss/ui/components/button"; import { Command, CommandCollection, @@ -696,14 +696,16 @@ export function AppCommand(): React.ReactElement { {aiState.referenceLinks.length > 0 && (
{aiState.referenceLinks.map((link, index) => ( - + ))}
)} diff --git a/apps/examples/calcom/components/settings/settings-mobile-header.tsx b/apps/examples/calcom/components/settings/settings-mobile-header.tsx index 87e228eef..665119c46 100644 --- a/apps/examples/calcom/components/settings/settings-mobile-header.tsx +++ b/apps/examples/calcom/components/settings/settings-mobile-header.tsx @@ -1,4 +1,4 @@ -import { Button } from "@coss/ui/components/button"; +import { buttonVariants } from "@coss/ui/components/button"; import { ArrowLeftIcon } from "lucide-react"; import Link from "next/link"; import type * as React from "react"; @@ -8,14 +8,14 @@ import { SettingsDrawer } from "@/components/settings/settings-drawer"; export function SettingsMobileHeader(): React.ReactElement { return ( - + ); diff --git a/apps/ui/AGENTS.md b/apps/ui/AGENTS.md index 6deb3eba2..e3c99b766 100644 --- a/apps/ui/AGENTS.md +++ b/apps/ui/AGENTS.md @@ -811,7 +811,8 @@ When creating a particle equivalent to an origin component: - Understand why certain classes were used in origin 4. **Look at existing particles** in the same category for consistency 5. **Understand the differences:** - - `asChild` → `render` prop + - `asChild` → `render` prop (triggers / composed parts — not Button-as-link) + - Button links → `buttonVariants` on `` / `Link` (not `Button render`) - `*Content` → `*Popup` or `*Panel` - `onSelect` → `onClick` (Menu) - `type="multiple"` → `multiple={true}` @@ -819,7 +820,7 @@ When creating a particle equivalent to an origin component: ### Key Migration Patterns -**asChild → render:** +**asChild → render (triggers / composed parts):** ```tsx // Origin (Radix) @@ -831,6 +832,23 @@ When creating a particle equivalent to an origin component: }>Open ``` +**Button links → `buttonVariants` (not `Button render`):** + +```tsx +// Origin (Radix / older coss) + +// or: + +// coss (Base UI Button) +import { buttonVariants } from "@/registry/default/ui/button"; + + + Login + +``` + **Component naming:** ```tsx @@ -1019,7 +1037,8 @@ const handleSubmit = async () => { - Avoid Next.js-specific APIs (`next/link`, `next/image`) unless the particle specifically demonstrates Next.js integration - Use standard HTML elements and React patterns -- For navigation, use standard `` tags or demonstrate with `render` prop +- For navigation that should look like a button, style `` / `Link` with `buttonVariants` — do not use `
{links?.doc && ( -
diff --git a/apps/ui/app/not-found.tsx b/apps/ui/app/not-found.tsx index 5dd71547a..8a1fd123a 100644 --- a/apps/ui/app/not-found.tsx +++ b/apps/ui/app/not-found.tsx @@ -6,7 +6,7 @@ import { import { RiArrowLeftLine } from "@remixicon/react"; import type { Metadata } from "next"; import Link from "next/link"; -import { Button } from "@/registry/default/ui/button"; +import { buttonVariants } from "@/registry/default/ui/button"; export const metadata: Metadata = { description: @@ -24,19 +24,13 @@ export default function NotFound() { moved.
-
diff --git a/apps/ui/app/page.tsx b/apps/ui/app/page.tsx index 4e706d5ba..e89186c7e 100644 --- a/apps/ui/app/page.tsx +++ b/apps/ui/app/page.tsx @@ -7,7 +7,7 @@ import { SiteFooter } from "@coss/ui/shared/site-footer"; import type { Metadata } from "next"; import Link from "next/link"; import { Badge } from "@/registry/default/ui/badge"; -import { Button } from "@/registry/default/ui/button"; +import { buttonVariants } from "@/registry/default/ui/button"; import { Card, CardFrame, @@ -39,16 +39,15 @@ export default function Page() { {description}
- - +
diff --git a/apps/ui/app/particles/particle-card.tsx b/apps/ui/app/particles/particle-card.tsx index 53e01a1b3..1bc6606fd 100644 --- a/apps/ui/app/particles/particle-card.tsx +++ b/apps/ui/app/particles/particle-card.tsx @@ -3,7 +3,7 @@ import { InformationCircleIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { cache } from "react"; import { Index } from "@/registry/__index__"; -import { Button } from "@/registry/default/ui/button"; +import { Button, buttonVariants } from "@/registry/default/ui/button"; import { Drawer, DrawerContent, @@ -84,9 +84,11 @@ export async function ParticleCard({ /> - } + className={buttonVariants({ + className: "text-sm", + size: "sm", + variant: "outline", + })} > View code @@ -115,19 +117,15 @@ export async function ParticleCard({

Code

-
+ ))} ); diff --git a/apps/ui/content/docs/(root)/changelog.mdx b/apps/ui/content/docs/(root)/changelog.mdx index 650b81ec5..7558a8eff 100644 --- a/apps/ui/content/docs/(root)/changelog.mdx +++ b/apps/ui/content/docs/(root)/changelog.mdx @@ -58,6 +58,54 @@ No change required for those composed parts after upgrading. For custom nested s After upgrading @coss/scroll-area, ScrollArea no longer applies overscroll-contain by default. Add overscrollContain only on nested scroll surfaces where wheel/touch should not chain to a parent scroller (dialogs, sheets, drawers, popups, sidebars, command panels). DialogPanel, SheetPanel, DrawerPanel, ComboboxList, AutocompleteList, and SidebarContent already pass overscrollContain—update local copies of those components if you vendor them. ``` +## July 30, 2026 + +### Button + +**`Button`** now wraps [Base UI Button](https://base-ui.com/react/components/button) instead of a custom `useRender` wrapper. Variants, sizes, and the built-in `loading` spinner are unchanged. + +Do **not** use `Button` for links. Base UI Button enforces button semantics, so links should be styled with **`buttonVariants`** on a plain `` or router `Link` — the same approach as [shadcn/ui](https://ui.shadcn.com/docs/components/button#as-link). + +When `loading` is `true`, `focusableWhenDisabled` defaults to `true` so focus is not lost during async actions. Disabled styles use `data-disabled` so they remain correct while the button is focusable. + +See the [Button](/ui/docs/components/button) docs and [migration guide](/ui/docs/radix-migration#button). + +**Migration:** + +```diff + import Link from "next/link" +- import { Button } from "@/components/ui/button" ++ import { buttonVariants } from "@/components/ui/button" + +- ++ ++ Login ++ +``` + +With variant/size: + +```diff +- ++ ++ Docs ++ +``` + +Trigger composition is unchanged — keep putting `Button` on the **other** component’s `render` prop: + +```tsx +}>Open +``` + +**Agent migration prompt:** + +```text +coss Button now wraps @base-ui/react/button. Stop using +// [!code word:buttonVariants] +import { buttonVariants } from "@/components/ui/button" + + + Login + ``` diff --git a/apps/ui/content/docs/(root)/skills.mdx b/apps/ui/content/docs/(root)/skills.mdx index c56ba2aca..802e84877 100644 --- a/apps/ui/content/docs/(root)/skills.mdx +++ b/apps/ui/content/docs/(root)/skills.mdx @@ -36,7 +36,7 @@ Tailwind v4 token usage, semantic color system, icon sizing rules, `data-slot` s ### Migration Patterns -Rules for migrating from shadcn/Radix to coss/Base UI — `asChild` to `render`, `onSelect` to `onClick`, Select items-first pattern, ToggleGroup `type` to `multiple`, and Slider scalar values. +Rules for migrating from shadcn/Radix to coss/Base UI — `asChild` to `render` on triggers, Button links via `buttonVariants` (not `Button render={}`), `onSelect` to `onClick`, Select items-first pattern, ToggleGroup `type` to `multiple`, and Slider scalar values. ### CLI and Registry diff --git a/apps/ui/content/docs/components/button.mdx b/apps/ui/content/docs/components/button.mdx index b3f86634e..7f67099db 100644 --- a/apps/ui/content/docs/components/button.mdx +++ b/apps/ui/content/docs/components/button.mdx @@ -1,10 +1,15 @@ --- title: Button description: A button or a component that looks like a button. + +links: + doc: https://base-ui.com/react/components/button --- +This component wraps [Base UI Button](https://base-ui.com/react/components/button). + ## Installation @@ -71,41 +76,65 @@ import { Button } from "@/components/ui/button" ## Link -You can use the [`render`](https://base-ui.com/react/utils/use-render#migrating-from-radix-ui) prop to make another component look like a button. Here's an example of a link that looks like a button. +Do not use `Button` for links. Base UI Button enforces button semantics (`role="button"`, keyboard interaction, disabled state), so it should not wrap an `` or router `Link`. + +To make a link look like a button, apply `buttonVariants` to the link element: ```tsx import Link from "next/link" -import { Button } from "@/components/ui/button" +import { buttonVariants } from "@/components/ui/button" export function LinkAsButton() { - return + return ( + + Login + + ) } ``` +Pass variant and size options the same way as on `Button`: + +```tsx + + Docs + +``` + ## API Reference -This is a custom component using Base UI's `useRender` hook, not a direct Base UI wrapper. +This component is built on [Base UI Button](https://base-ui.com/react/components/button). It accepts the same props as Base UI `Button`, plus the coss styling and loading helpers below. ### Button A button component with variant, size, and loading states. -| Prop | Type | Default | Description | -| --------- | ----------------------------------------------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------- | -| `variant` | `"default" \| "destructive" \| "destructive-outline" \| "ghost" \| "link" \| "outline" \| "secondary"` | `"default"` | Controls the button styling | -| `size` | `"default" \| "icon" \| "icon-lg" \| "icon-sm" \| "icon-xl" \| "icon-xs" \| "lg" \| "sm" \| "xl" \| "xs"` | `"default"` | Controls the button size | -| `loading` | `boolean` | `false` | Shows a spinner and forces disabled button behavior while loading | -| `render` | `React.ReactElement` | - | Render as a different element (e.g., Link) | +| Prop | Type | Default | Description | +| ------------------------ | ------------------------------------------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------- | +| `variant` | `"default" \| "destructive" \| "destructive-outline" \| "ghost" \| "link" \| "outline" \| "secondary"` | `"default"` | Controls the button styling | +| `size` | `"default" \| "icon" \| "icon-lg" \| "icon-sm" \| "icon-xl" \| "icon-xs" \| "lg" \| "sm" \| "xl" \| "xs"` | `"default"` | Controls the button size | +| `loading` | `boolean` | `false` | Shows a spinner and disables the button while loading | +| `focusableWhenDisabled` | `boolean` | `false`\* | Keeps the button focusable while disabled (Base UI). Defaults to `true` when `loading` is `true` | +| `disabled` | `boolean` | `false` | Disables the button | +| `nativeButton` | `boolean` | `true` | Set to `false` when rendering a non-`;\n}\n", + "content": "import Link from \"next/link\";\nimport { buttonVariants } from \"@/registry/default/ui/button\";\n\nexport default function Particle() {\n return (\n \n Link\n \n );\n}\n", "type": "registry:block" } ], diff --git a/apps/ui/public/r/p-button-18.json b/apps/ui/public/r/p-button-18.json index f22844037..244ed1a4b 100644 --- a/apps/ui/public/r/p-button-18.json +++ b/apps/ui/public/r/p-button-18.json @@ -9,7 +9,7 @@ "files": [ { "path": "registry/default/particles/p-button-18.tsx", - "content": "import { Button } from \"@/registry/default/ui/button\";\nimport { Spinner } from \"@/registry/default/ui/spinner\";\n\nexport default function Particle() {\n return (\n \n );\n}\n", + "content": "import { Button } from \"@/registry/default/ui/button\";\nimport { Spinner } from \"@/registry/default/ui/spinner\";\n\nexport default function Particle() {\n return (\n \n );\n}\n", "type": "registry:block" } ], diff --git a/apps/ui/public/r/p-command-2.json b/apps/ui/public/r/p-command-2.json index 050f59862..1ac8eca93 100644 --- a/apps/ui/public/r/p-command-2.json +++ b/apps/ui/public/r/p-command-2.json @@ -19,7 +19,7 @@ "files": [ { "path": "registry/default/particles/p-command-2.tsx", - "content": "\"use client\";\n\nimport {\n ArrowDownIcon,\n ArrowLeftIcon,\n ArrowUpIcon,\n CircleQuestionMarkIcon,\n CornerDownLeftIcon,\n SearchIcon,\n SparklesIcon,\n} from \"lucide-react\";\nimport Link from \"next/link\";\nimport {\n Fragment,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { useAutocompleteFilter } from \"@/registry/default/ui/autocomplete\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport {\n Command,\n CommandCollection,\n CommandCreateHandle,\n CommandDialog,\n CommandDialogPopup,\n CommandEmpty,\n CommandFooter,\n CommandGroup,\n CommandGroupLabel,\n CommandInput,\n CommandItem,\n CommandList,\n CommandPanel,\n CommandSeparator,\n CommandShortcut,\n} from \"@/registry/default/ui/command\";\nimport { EmptyMedia } from \"@/registry/default/ui/empty\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Kbd, KbdGroup } from \"@/registry/default/ui/kbd\";\nimport { ScrollArea } from \"@/registry/default/ui/scroll-area\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { Spinner } from \"@/registry/default/ui/spinner\";\n\ninterface Item {\n value: string;\n label: string;\n shortcut?: string;\n keywords?: string[];\n}\n\ninterface Group {\n value: string;\n items: Item[];\n}\n\nconst commandGroups: Group[] = [\n {\n items: [\n {\n keywords: [\"dash\"],\n label: \"Dashboard\",\n shortcut: \"d\",\n value: \"dashboard\",\n },\n {\n keywords: [\"proj\"],\n label: \"Projects\",\n shortcut: \"p\",\n value: \"projects\",\n },\n { keywords: [\"team\"], label: \"Team\", shortcut: \"t\", value: \"team\" },\n ],\n value: \"Pages\",\n },\n {\n items: [\n {\n keywords: [\"prof\"],\n label: \"Profile\",\n shortcut: \"p s\",\n value: \"profile\",\n },\n {\n keywords: [\"acc\"],\n label: \"Account\",\n shortcut: \"a s\",\n value: \"account\",\n },\n {\n keywords: [\"pref\"],\n label: \"Preferences\",\n shortcut: \"p r\",\n value: \"preferences\",\n },\n ],\n value: \"Settings\",\n },\n {\n items: [\n {\n keywords: [\"docs\"],\n label: \"Documentation\",\n shortcut: \"d o\",\n value: \"docs\",\n },\n {\n keywords: [\"sup\"],\n label: \"Support\",\n shortcut: \"s u\",\n value: \"support\",\n },\n {\n keywords: [\"feed\"],\n label: \"Feedback\",\n shortcut: \"f b\",\n value: \"feedback\",\n },\n ],\n value: \"Help\",\n },\n];\n\nconst MOCK_AI_RESPONSE = `To create a new project, navigate to the Projects page and click the \"New Project\" button in the top right corner. You'll be prompted to enter a project name and description.\n\nOnce created, you can invite team members by clicking the \"Share\" button and entering their email addresses. Team members will receive an invitation link via email or you can add them manually by clicking the \"Add Team Member\" button in the project settings.\n\nYou can customize project settings at any time by clicking the settings icon in the project header. For more information, see the Project Settings documentation.`;\n\nconst MOCK_REFERENCE_LINKS = [\n { title: \"Creating Projects\", url: \"/docs/projects/create\" },\n { title: \"Team Collaboration\", url: \"/docs/team/collaborate\" },\n { title: \"Project Settings\", url: \"/docs/projects/settings\" },\n];\n\nexport const commandHandle: ReturnType =\n CommandCreateHandle();\n\ninterface AIState {\n mode: boolean;\n query: string;\n submittedQuery: string;\n response: string;\n referenceLinks: Array<{ title: string; url: string }>;\n isGenerating: boolean;\n error: string | null;\n}\n\nconst initialAIState: AIState = {\n error: null,\n isGenerating: false,\n mode: false,\n query: \"\",\n referenceLinks: [],\n response: \"\",\n submittedQuery: \"\",\n};\n\nfunction markdownToSafeHTML(markdown: string): string {\n // Simple markdown to HTML converter for demo purposes\n return markdown\n .split(\"\\n\\n\")\n .map((para) => `

${para}

`)\n .join(\"\");\n}\n\nexport default function PCommand2() {\n const [open, setOpen] = useState(false);\n const [aiState, setAIState] = useState(initialAIState);\n const [searchQuery, setSearchQuery] = useState(\"\");\n const aiInputRef = useRef(null);\n const searchInputRef = useRef(null);\n const abortControllerRef = useRef(null);\n const commandResetKeyRef = useRef(0);\n\n // Cleanup on unmount\n useEffect(() => {\n return () => {\n abortControllerRef.current?.abort();\n };\n }, []);\n\n const resetAIState = useCallback(() => {\n abortControllerRef.current?.abort();\n setAIState(initialAIState);\n }, []);\n\n const handleItemClick = useCallback(() => {\n setOpen(false);\n }, []);\n\n const handleBackToSearch = useCallback(() => {\n resetAIState();\n setSearchQuery(\"\");\n commandResetKeyRef.current += 1;\n searchInputRef.current?.focus();\n }, [resetAIState]);\n\n const handleGenerateAI = useCallback(\n async (queryOverride?: string) => {\n const query = queryOverride || aiState.query;\n if (!query.trim()) return;\n\n abortControllerRef.current?.abort();\n const controller = new AbortController();\n abortControllerRef.current = controller;\n\n setAIState((prev) => ({\n ...prev,\n error: null,\n isGenerating: true,\n query: \"\",\n referenceLinks: [],\n response: \"\",\n submittedQuery: query,\n }));\n\n try {\n await new Promise((resolve, reject) => {\n const timeout = setTimeout(resolve, 1500);\n controller.signal.addEventListener(\"abort\", () => {\n clearTimeout(timeout);\n reject(new Error(\"aborted\"));\n });\n });\n\n if (controller.signal.aborted) return;\n\n setAIState((prev) => ({\n ...prev,\n isGenerating: false,\n referenceLinks: MOCK_REFERENCE_LINKS,\n response: MOCK_AI_RESPONSE,\n }));\n } catch (error) {\n if (error instanceof Error && error.message === \"aborted\") {\n return;\n }\n\n if (controller.signal.aborted) return;\n\n setAIState((prev) => ({\n ...prev,\n error: \"Failed to generate response. Please try again.\",\n isGenerating: false,\n }));\n }\n },\n [aiState.query],\n );\n\n const handleAskAI = useCallback(() => {\n const currentQuery = searchQuery;\n setSearchQuery(\"\");\n\n if (currentQuery.trim()) {\n setAIState((prev) => ({ ...prev, mode: true }));\n handleGenerateAI(currentQuery);\n } else {\n setAIState((prev) => ({ ...prev, mode: true, query: \"\" }));\n aiInputRef.current?.focus();\n }\n }, [searchQuery, handleGenerateAI]);\n\n const { contains } = useAutocompleteFilter({ sensitivity: \"base\" });\n\n const filterItem = useCallback(\n (itemValue: unknown, query: string): boolean => {\n if (typeof itemValue !== \"object\" || itemValue === null) {\n return false;\n }\n\n const item = itemValue as Item;\n\n if (contains(item.label, query)) {\n return true;\n }\n\n if (contains(item.value, query)) {\n return true;\n }\n\n if (item.keywords?.some((keyword) => contains(keyword, query))) {\n return true;\n }\n\n return false;\n },\n [contains],\n );\n\n useEffect(() => {\n if (!open || !aiState.mode) return;\n\n const handleEscape = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n handleBackToSearch();\n }\n };\n\n document.addEventListener(\"keydown\", handleEscape, true);\n return () => document.removeEventListener(\"keydown\", handleEscape, true);\n }, [open, aiState.mode, handleBackToSearch]);\n\n useEffect(() => {\n if (aiState.mode && !aiState.isGenerating) {\n aiInputRef.current?.focus();\n }\n }, [aiState.mode, aiState.isGenerating]);\n\n const hasResults = useMemo(\n () =>\n !searchQuery.trim() ||\n commandGroups.some((group) =>\n group.items.some((item) => filterItem(item, searchQuery)),\n ),\n [searchQuery, filterItem],\n );\n\n const handleOpenChange = useCallback(\n (newOpen: boolean) => {\n setOpen(newOpen);\n if (!newOpen) {\n setSearchQuery(\"\");\n resetAIState();\n }\n },\n [resetAIState],\n );\n\n return (\n <>\n \n \n \n {!aiState.mode ? (\n \n
\n setSearchQuery(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Tab\") {\n e.preventDefault();\n handleAskAI();\n }\n if (\n e.key === \"Enter\" &&\n !hasResults &&\n searchQuery.trim()\n ) {\n e.preventDefault();\n handleAskAI();\n }\n }}\n placeholder=\"Type a command or search...\"\n ref={searchInputRef}\n value={searchQuery}\n />\n \n \n Ask AI\n Tab\n \n
\n \n \n {searchQuery.trim() && (\n
\n \n \n \n

No results found.

\n

\n Press Enter to ask AI about:\n
{\" \"}\n \n {searchQuery}\n \n

\n
\n )}\n
\n \n {(group: Group) => (\n \n \n {group.value}\n \n {(item: Item) => (\n \n {item.label}\n {item.shortcut && (\n \n {item.shortcut}\n \n )}\n \n )}\n \n \n \n \n )}\n \n
\n \n {hasResults ? (\n <>\n
\n
\n \n \n \n \n \n \n \n \n Navigate\n
\n
\n \n \n \n Open\n
\n
\n
\n Esc\n Close\n
\n \n ) : (\n
\n Esc\n Close\n
\n )}\n
\n \n ) : (\n \n
\n
\n
\n \n \n
\n \n setAIState((prev) => ({\n ...prev,\n query: e.target.value,\n }))\n }\n onKeyDown={(e) => {\n if (e.key === \"Enter\" && !aiState.isGenerating) {\n handleGenerateAI();\n }\n if (e.key === \"Escape\") {\n e.preventDefault();\n handleBackToSearch();\n }\n }}\n placeholder=\"Ask AI anything…\"\n ref={aiInputRef}\n size=\"lg\"\n value={aiState.query}\n />\n
\n
\n \n \n Back to search\n Esc\n \n \n \n \n
\n {!aiState.isGenerating &&\n !aiState.response &&\n !aiState.error && (\n
\n

\n Ask AI anything and press Enter to get\n started.\n

\n
\n )}\n\n {aiState.error && (\n \n {aiState.error}\n
\n )}\n\n {aiState.isGenerating && (\n
\n
\n \n \n \n \n
\n
\n \n \n \n
\n
\n \n \n \n \n
\n
\n )}\n\n {aiState.response && !aiState.isGenerating && (\n <>\n \n {aiState.referenceLinks.length > 0 && (\n
\n {aiState.referenceLinks.map((link, index) => (\n }\n size=\"sm\"\n variant=\"secondary\"\n >\n {link.title}\n \n ))}\n
\n )}\n \n )}\n \n
\n
\n\n \n {aiState.isGenerating ? (\n
\n
\n \n
\n Generating response…\n
\n ) : aiState.response ? (\n
\n
\n \n
\n You asked: "{aiState.submittedQuery}"\n
\n ) : (\n
\n \n \n \n Ask AI\n
\n )}\n
\n
\n )}\n
\n \n \n );\n}\n", + "content": "\"use client\";\n\nimport {\n ArrowDownIcon,\n ArrowLeftIcon,\n ArrowUpIcon,\n CircleQuestionMarkIcon,\n CornerDownLeftIcon,\n SearchIcon,\n SparklesIcon,\n} from \"lucide-react\";\nimport Link from \"next/link\";\nimport {\n Fragment,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { useAutocompleteFilter } from \"@/registry/default/ui/autocomplete\";\nimport { Button, buttonVariants } from \"@/registry/default/ui/button\";\nimport {\n Command,\n CommandCollection,\n CommandCreateHandle,\n CommandDialog,\n CommandDialogPopup,\n CommandEmpty,\n CommandFooter,\n CommandGroup,\n CommandGroupLabel,\n CommandInput,\n CommandItem,\n CommandList,\n CommandPanel,\n CommandSeparator,\n CommandShortcut,\n} from \"@/registry/default/ui/command\";\nimport { EmptyMedia } from \"@/registry/default/ui/empty\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { Kbd, KbdGroup } from \"@/registry/default/ui/kbd\";\nimport { ScrollArea } from \"@/registry/default/ui/scroll-area\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport { Spinner } from \"@/registry/default/ui/spinner\";\n\ninterface Item {\n value: string;\n label: string;\n shortcut?: string;\n keywords?: string[];\n}\n\ninterface Group {\n value: string;\n items: Item[];\n}\n\nconst commandGroups: Group[] = [\n {\n items: [\n {\n keywords: [\"dash\"],\n label: \"Dashboard\",\n shortcut: \"d\",\n value: \"dashboard\",\n },\n {\n keywords: [\"proj\"],\n label: \"Projects\",\n shortcut: \"p\",\n value: \"projects\",\n },\n { keywords: [\"team\"], label: \"Team\", shortcut: \"t\", value: \"team\" },\n ],\n value: \"Pages\",\n },\n {\n items: [\n {\n keywords: [\"prof\"],\n label: \"Profile\",\n shortcut: \"p s\",\n value: \"profile\",\n },\n {\n keywords: [\"acc\"],\n label: \"Account\",\n shortcut: \"a s\",\n value: \"account\",\n },\n {\n keywords: [\"pref\"],\n label: \"Preferences\",\n shortcut: \"p r\",\n value: \"preferences\",\n },\n ],\n value: \"Settings\",\n },\n {\n items: [\n {\n keywords: [\"docs\"],\n label: \"Documentation\",\n shortcut: \"d o\",\n value: \"docs\",\n },\n {\n keywords: [\"sup\"],\n label: \"Support\",\n shortcut: \"s u\",\n value: \"support\",\n },\n {\n keywords: [\"feed\"],\n label: \"Feedback\",\n shortcut: \"f b\",\n value: \"feedback\",\n },\n ],\n value: \"Help\",\n },\n];\n\nconst MOCK_AI_RESPONSE = `To create a new project, navigate to the Projects page and click the \"New Project\" button in the top right corner. You'll be prompted to enter a project name and description.\n\nOnce created, you can invite team members by clicking the \"Share\" button and entering their email addresses. Team members will receive an invitation link via email or you can add them manually by clicking the \"Add Team Member\" button in the project settings.\n\nYou can customize project settings at any time by clicking the settings icon in the project header. For more information, see the Project Settings documentation.`;\n\nconst MOCK_REFERENCE_LINKS = [\n { title: \"Creating Projects\", url: \"/docs/projects/create\" },\n { title: \"Team Collaboration\", url: \"/docs/team/collaborate\" },\n { title: \"Project Settings\", url: \"/docs/projects/settings\" },\n];\n\nexport const commandHandle: ReturnType =\n CommandCreateHandle();\n\ninterface AIState {\n mode: boolean;\n query: string;\n submittedQuery: string;\n response: string;\n referenceLinks: Array<{ title: string; url: string }>;\n isGenerating: boolean;\n error: string | null;\n}\n\nconst initialAIState: AIState = {\n error: null,\n isGenerating: false,\n mode: false,\n query: \"\",\n referenceLinks: [],\n response: \"\",\n submittedQuery: \"\",\n};\n\nfunction markdownToSafeHTML(markdown: string): string {\n // Simple markdown to HTML converter for demo purposes\n return markdown\n .split(\"\\n\\n\")\n .map((para) => `

${para}

`)\n .join(\"\");\n}\n\nexport default function PCommand2() {\n const [open, setOpen] = useState(false);\n const [aiState, setAIState] = useState(initialAIState);\n const [searchQuery, setSearchQuery] = useState(\"\");\n const aiInputRef = useRef(null);\n const searchInputRef = useRef(null);\n const abortControllerRef = useRef(null);\n const commandResetKeyRef = useRef(0);\n\n // Cleanup on unmount\n useEffect(() => {\n return () => {\n abortControllerRef.current?.abort();\n };\n }, []);\n\n const resetAIState = useCallback(() => {\n abortControllerRef.current?.abort();\n setAIState(initialAIState);\n }, []);\n\n const handleItemClick = useCallback(() => {\n setOpen(false);\n }, []);\n\n const handleBackToSearch = useCallback(() => {\n resetAIState();\n setSearchQuery(\"\");\n commandResetKeyRef.current += 1;\n searchInputRef.current?.focus();\n }, [resetAIState]);\n\n const handleGenerateAI = useCallback(\n async (queryOverride?: string) => {\n const query = queryOverride || aiState.query;\n if (!query.trim()) return;\n\n abortControllerRef.current?.abort();\n const controller = new AbortController();\n abortControllerRef.current = controller;\n\n setAIState((prev) => ({\n ...prev,\n error: null,\n isGenerating: true,\n query: \"\",\n referenceLinks: [],\n response: \"\",\n submittedQuery: query,\n }));\n\n try {\n await new Promise((resolve, reject) => {\n const timeout = setTimeout(resolve, 1500);\n controller.signal.addEventListener(\"abort\", () => {\n clearTimeout(timeout);\n reject(new Error(\"aborted\"));\n });\n });\n\n if (controller.signal.aborted) return;\n\n setAIState((prev) => ({\n ...prev,\n isGenerating: false,\n referenceLinks: MOCK_REFERENCE_LINKS,\n response: MOCK_AI_RESPONSE,\n }));\n } catch (error) {\n if (error instanceof Error && error.message === \"aborted\") {\n return;\n }\n\n if (controller.signal.aborted) return;\n\n setAIState((prev) => ({\n ...prev,\n error: \"Failed to generate response. Please try again.\",\n isGenerating: false,\n }));\n }\n },\n [aiState.query],\n );\n\n const handleAskAI = useCallback(() => {\n const currentQuery = searchQuery;\n setSearchQuery(\"\");\n\n if (currentQuery.trim()) {\n setAIState((prev) => ({ ...prev, mode: true }));\n handleGenerateAI(currentQuery);\n } else {\n setAIState((prev) => ({ ...prev, mode: true, query: \"\" }));\n aiInputRef.current?.focus();\n }\n }, [searchQuery, handleGenerateAI]);\n\n const { contains } = useAutocompleteFilter({ sensitivity: \"base\" });\n\n const filterItem = useCallback(\n (itemValue: unknown, query: string): boolean => {\n if (typeof itemValue !== \"object\" || itemValue === null) {\n return false;\n }\n\n const item = itemValue as Item;\n\n if (contains(item.label, query)) {\n return true;\n }\n\n if (contains(item.value, query)) {\n return true;\n }\n\n if (item.keywords?.some((keyword) => contains(keyword, query))) {\n return true;\n }\n\n return false;\n },\n [contains],\n );\n\n useEffect(() => {\n if (!open || !aiState.mode) return;\n\n const handleEscape = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n handleBackToSearch();\n }\n };\n\n document.addEventListener(\"keydown\", handleEscape, true);\n return () => document.removeEventListener(\"keydown\", handleEscape, true);\n }, [open, aiState.mode, handleBackToSearch]);\n\n useEffect(() => {\n if (aiState.mode && !aiState.isGenerating) {\n aiInputRef.current?.focus();\n }\n }, [aiState.mode, aiState.isGenerating]);\n\n const hasResults = useMemo(\n () =>\n !searchQuery.trim() ||\n commandGroups.some((group) =>\n group.items.some((item) => filterItem(item, searchQuery)),\n ),\n [searchQuery, filterItem],\n );\n\n const handleOpenChange = useCallback(\n (newOpen: boolean) => {\n setOpen(newOpen);\n if (!newOpen) {\n setSearchQuery(\"\");\n resetAIState();\n }\n },\n [resetAIState],\n );\n\n return (\n <>\n \n \n \n {!aiState.mode ? (\n \n
\n setSearchQuery(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Tab\") {\n e.preventDefault();\n handleAskAI();\n }\n if (\n e.key === \"Enter\" &&\n !hasResults &&\n searchQuery.trim()\n ) {\n e.preventDefault();\n handleAskAI();\n }\n }}\n placeholder=\"Type a command or search...\"\n ref={searchInputRef}\n value={searchQuery}\n />\n \n \n Ask AI\n Tab\n \n
\n \n \n {searchQuery.trim() && (\n
\n \n \n \n

No results found.

\n

\n Press Enter to ask AI about:\n
{\" \"}\n \n {searchQuery}\n \n

\n
\n )}\n
\n \n {(group: Group) => (\n \n \n {group.value}\n \n {(item: Item) => (\n \n {item.label}\n {item.shortcut && (\n \n {item.shortcut}\n \n )}\n \n )}\n \n \n \n \n )}\n \n
\n \n {hasResults ? (\n <>\n
\n
\n \n \n \n \n \n \n \n \n Navigate\n
\n
\n \n \n \n Open\n
\n
\n
\n Esc\n Close\n
\n \n ) : (\n
\n Esc\n Close\n
\n )}\n
\n \n ) : (\n \n
\n
\n
\n \n \n
\n \n setAIState((prev) => ({\n ...prev,\n query: e.target.value,\n }))\n }\n onKeyDown={(e) => {\n if (e.key === \"Enter\" && !aiState.isGenerating) {\n handleGenerateAI();\n }\n if (e.key === \"Escape\") {\n e.preventDefault();\n handleBackToSearch();\n }\n }}\n placeholder=\"Ask AI anything…\"\n ref={aiInputRef}\n size=\"lg\"\n value={aiState.query}\n />\n
\n
\n \n \n Back to search\n Esc\n \n \n \n \n
\n {!aiState.isGenerating &&\n !aiState.response &&\n !aiState.error && (\n
\n

\n Ask AI anything and press Enter to get\n started.\n

\n
\n )}\n\n {aiState.error && (\n \n {aiState.error}\n
\n )}\n\n {aiState.isGenerating && (\n
\n
\n \n \n \n \n
\n
\n \n \n \n
\n
\n \n \n \n \n
\n
\n )}\n\n {aiState.response && !aiState.isGenerating && (\n <>\n \n {aiState.referenceLinks.length > 0 && (\n
\n {aiState.referenceLinks.map((link, index) => (\n \n {link.title}\n \n ))}\n
\n )}\n \n )}\n \n
\n
\n\n \n {aiState.isGenerating ? (\n
\n
\n \n
\n Generating response…\n
\n ) : aiState.response ? (\n
\n
\n \n
\n You asked: "{aiState.submittedQuery}"\n
\n ) : (\n
\n \n \n \n Ask AI\n
\n )}\n
\n
\n )}\n
\n \n \n );\n}\n", "type": "registry:block" } ], diff --git a/apps/ui/public/r/p-drawer-11.json b/apps/ui/public/r/p-drawer-11.json index d0eca4e04..b5d014f2e 100644 --- a/apps/ui/public/r/p-drawer-11.json +++ b/apps/ui/public/r/p-drawer-11.json @@ -9,7 +9,7 @@ "files": [ { "path": "registry/default/particles/p-drawer-11.tsx", - "content": "import Link from \"next/link\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport {\n Drawer,\n DrawerClose,\n DrawerHeader,\n DrawerPanel,\n DrawerPopup,\n DrawerTitle,\n DrawerTrigger,\n} from \"@/registry/default/ui/drawer\";\n\nexport default function Particle() {\n return (\n \n }>\n Open menu\n \n \n \n Menu\n \n \n \n \n \n \n );\n}\n", + "content": "import Link from \"next/link\";\nimport { Button, buttonVariants } from \"@/registry/default/ui/button\";\nimport {\n Drawer,\n DrawerClose,\n DrawerHeader,\n DrawerPanel,\n DrawerPopup,\n DrawerTitle,\n DrawerTrigger,\n} from \"@/registry/default/ui/drawer\";\n\nexport default function Particle() {\n return (\n \n }>\n Open menu\n \n \n \n Menu\n \n \n \n \n \n \n );\n}\n", "type": "registry:block" } ], diff --git a/apps/ui/public/r/p-drawer-14.json b/apps/ui/public/r/p-drawer-14.json index 2f112aa89..027a40d6f 100644 --- a/apps/ui/public/r/p-drawer-14.json +++ b/apps/ui/public/r/p-drawer-14.json @@ -9,7 +9,7 @@ "files": [ { "path": "registry/default/particles/p-drawer-14.tsx", - "content": "import Link from \"next/link\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport {\n Drawer,\n DrawerHeader,\n DrawerPanel,\n DrawerPopup,\n DrawerSwipeArea,\n DrawerTitle,\n} from \"@/registry/default/ui/drawer\";\n\nexport default function Particle() {\n return (\n
\n \n \n \n Swipe area\n \n \n\n
\n

\n Swipe from the left edge to open the menu.\n

\n
\n\n \n \n Menu\n \n \n \n \n \n
\n
\n );\n}\n", + "content": "import Link from \"next/link\";\nimport { buttonVariants } from \"@/registry/default/ui/button\";\nimport {\n Drawer,\n DrawerHeader,\n DrawerPanel,\n DrawerPopup,\n DrawerSwipeArea,\n DrawerTitle,\n} from \"@/registry/default/ui/drawer\";\n\nexport default function Particle() {\n return (\n
\n \n \n \n Swipe area\n \n \n\n
\n

\n Swipe from the left edge to open the menu.\n

\n
\n\n \n \n Menu\n \n \n \n \n \n
\n
\n );\n}\n", "type": "registry:block" } ], diff --git a/apps/ui/public/r/p-pagination-2.json b/apps/ui/public/r/p-pagination-2.json index 42aad84bc..0edf340e1 100644 --- a/apps/ui/public/r/p-pagination-2.json +++ b/apps/ui/public/r/p-pagination-2.json @@ -9,7 +9,7 @@ "files": [ { "path": "registry/default/particles/p-pagination-2.tsx", - "content": "import Link from \"next/link\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport {\n Pagination,\n PaginationContent,\n PaginationItem,\n} from \"@/registry/default/ui/pagination\";\n\ntype PaginationProps = {\n currentPage: number;\n totalPages: number;\n};\n\nexport default function Particle({ currentPage, totalPages }: PaginationProps) {\n return (\n \n \n \n \n )\n }\n variant=\"outline\"\n >\n Previous\n \n \n \n \n )\n }\n variant=\"outline\"\n >\n Next\n \n \n \n \n );\n}\n", + "content": "import Link from \"next/link\";\nimport { Button, buttonVariants } from \"@/registry/default/ui/button\";\nimport {\n Pagination,\n PaginationContent,\n PaginationItem,\n} from \"@/registry/default/ui/pagination\";\n\ntype PaginationProps = {\n currentPage: number;\n totalPages: number;\n};\n\nexport default function Particle({ currentPage, totalPages }: PaginationProps) {\n return (\n \n \n \n {currentPage === 1 ? (\n \n ) : (\n \n Previous\n \n )}\n \n \n {currentPage === totalPages ? (\n \n ) : (\n \n Next\n \n )}\n \n \n \n );\n}\n", "type": "registry:block" } ], diff --git a/apps/ui/public/r/registry.json b/apps/ui/public/r/registry.json index c8034a72a..66c04d53e 100644 --- a/apps/ui/public/r/registry.json +++ b/apps/ui/public/r/registry.json @@ -2639,7 +2639,7 @@ "categories": [ "button" ], - "description": "Link rendered as button", + "description": "Link styled with buttonVariants", "files": [ { "path": "registry/default/particles/p-button-17.tsx", diff --git a/apps/ui/public/r/sidebar.json b/apps/ui/public/r/sidebar.json index 4ed5003c3..242faa1e9 100644 --- a/apps/ui/public/r/sidebar.json +++ b/apps/ui/public/r/sidebar.json @@ -15,7 +15,7 @@ "files": [ { "path": "registry/default/ui/sidebar.tsx", - "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { PanelLeftIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useMediaQuery } from \"@/registry/default/hooks/use-media-query\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { Button } from \"@/registry/default/ui/button\";\nimport { Input } from \"@/registry/default/ui/input\";\nimport { ScrollArea } from \"@/registry/default/ui/scroll-area\";\nimport { Separator } from \"@/registry/default/ui/separator\";\nimport {\n Sheet,\n SheetDescription,\n SheetHeader,\n SheetPopup,\n SheetTitle,\n} from \"@/registry/default/ui/sheet\";\nimport { Skeleton } from \"@/registry/default/ui/skeleton\";\nimport {\n Tooltip,\n TooltipPopup,\n TooltipTrigger,\n} from \"@/registry/default/ui/tooltip\";\n\nconst SIDEBAR_COOKIE_NAME: string = \"sidebar_state\";\nconst SIDEBAR_COOKIE_MAX_AGE: number = 60 * 60 * 24 * 7;\nconst SIDEBAR_WIDTH: string = \"16rem\";\nconst SIDEBAR_WIDTH_MOBILE: string = \"18rem\";\nconst SIDEBAR_WIDTH_ICON: string = \"3rem\";\nconst SIDEBAR_KEYBOARD_SHORTCUT: string = \"b\";\n\nconst sidebarMenuButtonVariants = cva(\n \"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0\",\n {\n defaultVariants: {\n size: \"default\",\n variant: \"default\",\n },\n variants: {\n size: {\n default: \"h-8 text-sm\",\n lg: \"h-12 text-sm group-data-[collapsible=icon]:p-0!\",\n sm: \"h-7 text-xs\",\n },\n variant: {\n default: \"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground\",\n outline:\n \"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]\",\n },\n },\n },\n);\n\nexport type SidebarContextProps = {\n state: \"expanded\" | \"collapsed\";\n open: boolean;\n setOpen: (open: boolean) => void;\n openMobile: boolean;\n setOpenMobile: (open: boolean) => void;\n isMobile: boolean;\n toggleSidebar: () => void;\n};\n\nexport const SidebarContext: React.Context =\n React.createContext(null);\n\nexport function useSidebar(): SidebarContextProps {\n const context = React.useContext(SidebarContext);\n if (!context) {\n throw new Error(\"useSidebar must be used within a SidebarProvider.\");\n }\n\n return context;\n}\n\nexport function SidebarProvider({\n defaultOpen = true,\n open: openProp,\n onOpenChange: setOpenProp,\n className,\n style,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & {\n defaultOpen?: boolean;\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n}): React.ReactElement {\n const isMobile = useMediaQuery(\"max-md\");\n const [openMobile, setOpenMobile] = React.useState(false);\n\n // This is the internal state of the sidebar.\n // We use openProp and setOpenProp for control from outside the component.\n const [_open, _setOpen] = React.useState(defaultOpen);\n const open = openProp ?? _open;\n const setOpen = React.useCallback(\n async (value: boolean | ((value: boolean) => boolean)) => {\n const openState = typeof value === \"function\" ? value(open) : value;\n if (setOpenProp) {\n setOpenProp(openState);\n } else {\n _setOpen(openState);\n }\n\n // This sets the cookie to keep the sidebar state.\n await cookieStore.set({\n expires: Date.now() + SIDEBAR_COOKIE_MAX_AGE * 1000,\n name: SIDEBAR_COOKIE_NAME,\n path: \"/\",\n value: String(openState),\n });\n },\n [setOpenProp, open],\n );\n\n // Helper to toggle the sidebar.\n const toggleSidebar = React.useCallback(() => {\n return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);\n }, [isMobile, setOpen]);\n\n // Adds a keyboard shortcut to toggle the sidebar.\n React.useEffect(() => {\n const handleKeyDown = (event: KeyboardEvent): void => {\n if (\n event.key === SIDEBAR_KEYBOARD_SHORTCUT &&\n (event.metaKey || event.ctrlKey)\n ) {\n event.preventDefault();\n toggleSidebar();\n }\n };\n\n window.addEventListener(\"keydown\", handleKeyDown);\n return () => window.removeEventListener(\"keydown\", handleKeyDown);\n }, [toggleSidebar]);\n\n // We add a state so that we can do data-state=\"expanded\" or \"collapsed\".\n // This makes it easier to style the sidebar with Tailwind classes.\n const state = open ? \"expanded\" : \"collapsed\";\n\n const contextValue = React.useMemo(\n () => ({\n isMobile,\n open,\n openMobile,\n setOpen,\n setOpenMobile,\n state,\n toggleSidebar,\n }),\n [state, open, setOpen, isMobile, openMobile, toggleSidebar],\n );\n\n return (\n \n \n {children}\n \n \n );\n}\n\nexport function Sidebar({\n side = \"left\",\n variant = \"sidebar\",\n collapsible = \"offcanvas\",\n className,\n children,\n ...props\n}: React.ComponentProps<\"div\"> & {\n side?: \"left\" | \"right\";\n variant?: \"sidebar\" | \"floating\" | \"inset\";\n collapsible?: \"offcanvas\" | \"icon\" | \"none\";\n}): React.ReactElement {\n const { isMobile, state, openMobile, setOpenMobile } = useSidebar();\n\n if (collapsible === \"none\") {\n return (\n \n {children}\n \n );\n }\n\n if (isMobile) {\n return (\n \n button]:hidden\"\n data-mobile=\"true\"\n data-sidebar=\"sidebar\"\n data-slot=\"sidebar\"\n side={side}\n style={\n {\n \"--sidebar-width\": SIDEBAR_WIDTH_MOBILE,\n } as React.CSSProperties\n }\n >\n \n Sidebar\n Displays the mobile sidebar.\n \n
{children}
\n \n
\n );\n }\n\n return (\n
``` -Composite approach (manual `Spinner` + `disabled`): +Composite approach (manual `Spinner` + `disabled` / `focusableWhenDisabled` as needed): ```tsx - @@ -84,6 +103,8 @@ Composite approach (manual `Spinner` + `disabled`): Prefer the `loading` prop for typical async actions. Use the composite approach when you need custom spinner placement or label. +Disabled styles use `data-disabled` (including when focusable while disabled). + ### More examples - default: `p-button-1` @@ -94,16 +115,22 @@ Prefer the `loading` prop for typical async actions. Use the composite approach - ghost: `p-button-6` - link: `p-button-7` - extra-small size: `p-button-8` +- link styled with `buttonVariants`: `p-button-17` +- built-in loading prop: `p-button-41` +- composite loading (Spinner + disabled): `p-button-18` ## Common pitfalls +- Using ` +``` + +```tsx +// coss / Base UI Button (and shadcn’s recommended link pattern) +import { buttonVariants } from "@/components/ui/button" + + + Login + +``` + +Do not write ` - + ); diff --git a/packages/ui/test/components/button.test.tsx b/packages/ui/test/components/button.test.tsx index a653fdde3..11cbbc63a 100644 --- a/packages/ui/test/components/button.test.tsx +++ b/packages/ui/test/components/button.test.tsx @@ -1,81 +1,70 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; -import * as React from "react"; +import type * as React from "react"; -type UseRenderConfig = { - defaultTagName: string; - render?: (...args: unknown[]) => unknown; - props: Record; +type ButtonPrimitiveProps = Record & { + children?: React.ReactNode; }; -const useRenderCalls: UseRenderConfig[] = []; -const mergePropsCalls: Array< - [Record, Record] -> = []; +const buttonPrimitiveCalls: ButtonPrimitiveProps[] = []; -function useRenderMock(config: UseRenderConfig) { - useRenderCalls.push(config); +function mockButtonPrimitive(props: ButtonPrimitiveProps) { + buttonPrimitiveCalls.push(props); return null; } -function mergePropsMock( - defaults: Record, - overrides: Record = {}, -) { - mergePropsCalls.push([defaults, overrides]); - return { ...defaults, ...overrides }; -} - -mock.module("@base-ui/react/use-render", () => ({ - useRender: useRenderMock, -})); - -mock.module("@base-ui/react/merge-props", () => ({ - mergeProps: mergePropsMock, +mock.module("@base-ui/react/button", () => ({ + Button: mockButtonPrimitive, })); -const { Button } = await import("../../src/components/button"); +const { Button: ButtonComponent } = await import("../../src/components/button"); -function lastUseRenderCall() { - const lastCall = useRenderCalls[useRenderCalls.length - 1]; - if (!lastCall) { - throw new Error("useRender was not called"); - } - return lastCall; +function Button(props: Parameters[0]) { + const element = ButtonComponent(props); + (element.type as (props: unknown) => unknown)(element.props); } -function lastMergePropsCall() { - const lastCall = mergePropsCalls[mergePropsCalls.length - 1]; +function lastButtonPrimitiveCall() { + const lastCall = buttonPrimitiveCalls[buttonPrimitiveCalls.length - 1]; if (!lastCall) { - throw new Error("mergeProps was not called"); + throw new Error("ButtonPrimitive was not called"); } return lastCall; } describe("Button", () => { beforeEach(() => { - useRenderCalls.length = 0; - mergePropsCalls.length = 0; + buttonPrimitiveCalls.length = 0; }); - test("sets base attributes and defaults type to button", () => { + test("sets base attributes and forwards children", () => { Button({ children: "Default label" }); - const call = lastUseRenderCall(); - expect(call.defaultTagName).toBe("button"); - expect(call.props["data-slot"]).toBe("button"); - expect(call.props.type).toBe("button"); + const call = lastButtonPrimitiveCall(); + expect(call["data-slot"]).toBe("button"); + expect(call.disabled).toBe(false); + expect(call.focusableWhenDisabled).toBe(false); + expect(call.children).toBeDefined(); }); - test("omits the default type when a custom render is provided", () => { - const render = () => React.createElement("div"); - Button({ render, type: "submit" }); + test("disables and stays focusable while loading", () => { + Button({ children: "Submit", loading: true }); + + const call = lastButtonPrimitiveCall(); + expect(call.disabled).toBe(true); + expect(call.focusableWhenDisabled).toBe(true); + expect(call["data-loading"]).toBe(""); + }); - const [defaults, overrides] = lastMergePropsCall(); - expect(defaults.type).toBeUndefined(); - expect(overrides.type).toBe("submit"); + test("respects an explicit focusableWhenDisabled override", () => { + Button({ + children: "Submit", + focusableWhenDisabled: false, + loading: true, + }); - const call = lastUseRenderCall(); - expect(call.render).toBe(render); + const call = lastButtonPrimitiveCall(); + expect(call.disabled).toBe(true); + expect(call.focusableWhenDisabled).toBe(false); }); test("merges variants, sizes, and custom className", () => { @@ -85,8 +74,8 @@ describe("Button", () => { variant: "destructive", }); - const call = lastUseRenderCall(); - const className = call.props.className as string; + const call = lastButtonPrimitiveCall(); + const className = call.className as string; expect(className).toContain("border-destructive"); expect(className).toContain("h-10"); expect(className).toContain("custom-class");