diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-created-success-dialog.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-created-success-dialog.tsx index 7db8649d06..73e06b4ee7 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-created-success-dialog.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-created-success-dialog.tsx @@ -4,7 +4,7 @@ import { ConfirmPopover } from "@/components/confirmation-popover"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { toast } from "@/components/ui/toaster"; import { ArrowRight, Check, CircleInfo, Key2, Plus } from "@unkey/icons"; -import { Button, CopyButton, InfoTooltip, VisibleButton } from "@unkey/ui"; +import { Button, Code, CopyButton, InfoTooltip, VisibleButton } from "@unkey/ui"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { UNNAMED_KEY } from "../create-key.constants"; @@ -220,27 +220,15 @@ export const KeyCreatedSuccessDialog = ({
Try It Out
-
-
-
-
-                      {showKeyInSnippet ? snippet : snippet.replace(keyData.key, maskedKey)}
-                    
-
-
- setShowKeyInSnippet(visible)} - title="Key Snippet" - /> - -
-
-
+ + + } + copyButton={} + > + {showKeyInSnippet ? snippet : snippet.replace(keyData.key, maskedKey)} +
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/secret-key.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/secret-key.tsx index 06c74d5302..2f4d86aab6 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/secret-key.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/secret-key.tsx @@ -2,7 +2,7 @@ import { cn } from "@/lib/utils"; import { CircleLock } from "@unkey/icons"; -import { Button, CopyButton, VisibleButton } from "@unkey/ui"; +import { CopyButton, VisibleButton } from "@unkey/ui"; import { useState } from "react"; const maskKey = (key: string): string => { @@ -45,17 +45,8 @@ export const SecretKey = ({ setIsVisible={(visible) => setIsVisible(visible)} title={title} /> - + +
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/logs-table.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/logs-table.tsx index 26706fae17..9d9f2f072b 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/logs-table.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/table/logs-table.tsx @@ -324,7 +324,7 @@ export const KeyDetailsLogsTable = ({ keyspaceId, keyId, selectedLog, onLogSelec className="pointer-events-auto" onClick={(e) => e.stopPropagation()} > - + @@ -336,7 +336,7 @@ export const KeyDetailsLogsTable = ({ keyspaceId, keyId, selectedLog, onLogSelec className="pointer-events-auto flex-shrink-0" onClick={(e) => e.stopPropagation()} > - + )} @@ -380,7 +380,7 @@ export const KeyDetailsLogsTable = ({ keyspaceId, keyId, selectedLog, onLogSelec className="pointer-events-auto" onClick={(e) => e.stopPropagation()} > - + @@ -392,7 +392,7 @@ export const KeyDetailsLogsTable = ({ keyspaceId, keyId, selectedLog, onLogSelec className="pointer-events-auto flex-shrink-0" onClick={(e) => e.stopPropagation()} > - + )} diff --git a/apps/dashboard/app/(app)/identities/[identityId]/page.tsx b/apps/dashboard/app/(app)/identities/[identityId]/page.tsx index 6890072c08..9baca8075d 100644 --- a/apps/dashboard/app/(app)/identities/[identityId]/page.tsx +++ b/apps/dashboard/app/(app)/identities/[identityId]/page.tsx @@ -2,7 +2,6 @@ import { notFound } from "next/navigation"; import { PageContent } from "@/components/page-content"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Code } from "@/components/ui/code"; import { Table, TableBody, @@ -15,7 +14,7 @@ import { getAuth } from "@/lib/auth"; import { clickhouse } from "@/lib/clickhouse"; import { db } from "@/lib/db"; import { formatNumber } from "@/lib/fmt"; -import { Badge, Button, CopyButton } from "@unkey/ui"; +import { Badge, Button, Code, CopyButton } from "@unkey/ui"; import { ChevronRight, Minus } from "lucide-react"; import ms from "ms"; import Link from "next/link"; @@ -84,7 +83,7 @@ export default async function Page(props: Props) {

Meta

{identity.meta ? ( - {JSON.stringify(identity.meta, null, 2)} + {JSON.stringify(identity.meta, null, 2)} ) : ( No metadata diff --git a/apps/dashboard/app/(app)/settings/billing/stripe/checkout/page.tsx b/apps/dashboard/app/(app)/settings/billing/stripe/checkout/page.tsx index 1479075312..7986bd4a69 100644 --- a/apps/dashboard/app/(app)/settings/billing/stripe/checkout/page.tsx +++ b/apps/dashboard/app/(app)/settings/billing/stripe/checkout/page.tsx @@ -1,8 +1,7 @@ -import { Code } from "@/components/ui/code"; import { getAuth } from "@/lib/auth"; import { db } from "@/lib/db"; import { stripeEnv } from "@/lib/env"; -import { Empty } from "@unkey/ui"; +import { Code, Empty } from "@unkey/ui"; import { redirect } from "next/navigation"; import Stripe from "stripe"; diff --git a/apps/dashboard/app/(app)/settings/billing/stripe/portal/page.tsx b/apps/dashboard/app/(app)/settings/billing/stripe/portal/page.tsx index 6ed1514060..a1546711af 100644 --- a/apps/dashboard/app/(app)/settings/billing/stripe/portal/page.tsx +++ b/apps/dashboard/app/(app)/settings/billing/stripe/portal/page.tsx @@ -1,8 +1,7 @@ -import { Code } from "@/components/ui/code"; import { getAuth } from "@/lib/auth"; import { db } from "@/lib/db"; import { stripeEnv } from "@/lib/env"; -import { Empty } from "@unkey/ui"; +import { Code, Empty } from "@unkey/ui"; import { redirect } from "next/navigation"; import Stripe from "stripe"; diff --git a/apps/dashboard/app/(app)/settings/root-keys/new/client.tsx b/apps/dashboard/app/(app)/settings/root-keys/new/client.tsx index 6012d25926..cbef5af73b 100644 --- a/apps/dashboard/app/(app)/settings/root-keys/new/client.tsx +++ b/apps/dashboard/app/(app)/settings/root-keys/new/client.tsx @@ -1,18 +1,12 @@ "use client"; -import { Code } from "@/components/ui/code"; +import { ConfirmPopover } from "@/components/confirmation-popover"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; +import { Check, CircleInfo, Key2 } from "@unkey/icons"; import { type UnkeyPermission, unkeyPermissionValidation } from "@unkey/rbac"; import { Button, @@ -22,14 +16,17 @@ import { CardHeader, CardTitle, Checkbox, + Code, CopyButton, + InfoTooltip, Input, VisibleButton, } from "@unkey/ui"; -import { ChevronRight } from "lucide-react"; +import { ArrowRight, ChevronRight } from "lucide-react"; import { useRouter } from "next/navigation"; import { createParser, parseAsArrayOf, useQueryState } from "nuqs"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { SecretKey } from "../../../apis/[apiId]/_components/create-key/components/secret-key"; import { apiPermissions, workspacePermissions } from "../[keyId]/permissions/permissions"; type Props = { @@ -39,6 +36,36 @@ type Props = { }[]; }; +type PermissionToggleProps = { + checked: boolean; + setChecked: (checked: boolean) => void; + label: string | React.ReactNode; + description: string; +}; + +const PermissionToggle: React.FC = ({ + checked, + setChecked, + label, + description, +}) => { + return ( +
+
+ { + setChecked(!checked); + }} + /> + +
+ +

{description}

+
+ ); +}; + const parseAsUnkeyPermission = createParser({ parse(queryValue) { const { success, data } = unkeyPermissionValidation.safeParse(queryValue); @@ -47,9 +74,17 @@ const parseAsUnkeyPermission = createParser({ serialize: String, }); +const UNNAMED_KEY = "Unnamed Key"; + export const Client: React.FC = ({ apis }) => { const router = useRouter(); const [name, setName] = useState(undefined); + const [showKeyInSnippet, setShowKeyInSnippet] = useState(false); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const [pendingAction, setPendingAction] = useState< + "close" | "create-another" | "go-to-details" | null + >(null); + const dividerRef = useRef(null); const [selectedPermissions, setSelectedPermissions] = useQueryState( "permissions", @@ -68,16 +103,18 @@ export const Client: React.FC = ({ apis }) => { }); const snippet = `curl -XPOST '${process.env.NEXT_PUBLIC_UNKEY_API_URL ?? "https://api.unkey.dev"}/v1/keys.createKey' \\ - -H 'Authorization: Bearer ${key.data?.key}' \\ - -H 'Content-Type: application/json' \\ - -d '{ - "prefix": "hello", - "apiId": "" - }'`; - - const maskedKey = `unkey_${"*".repeat(key.data?.key.split("_").at(1)?.length ?? 0)}`; - const [showKey, setShowKey] = useState(false); - const [showKeyInSnippet, setShowKeyInSnippet] = useState(false); + -H 'Authorization: Bearer ${key.data?.key}' \\ + -H 'Content-Type: application/json' \\ + -d '{ + "prefix": "hello", + "apiId": "" + }'`; + + const split = key.data?.key?.split("_") ?? []; + const maskedKey = + split.length >= 2 + ? `${split.at(0)}_${"*".repeat(split.at(1)?.length ?? 0)}` + : "*".repeat(split.at(0)?.length ?? 0); const handleSetChecked = (permission: UnkeyPermission, checked: boolean) => { setSelectedPermissions((prevPermissions) => { @@ -120,6 +157,56 @@ export const Client: React.FC = ({ apis }) => { setCardStatesMap(initialCardStates); }, []); // Execute ones on the first load + const handleCloseAttempt = (action: "close" | "create-another" | "go-to-details" = "close") => { + setPendingAction(action); + setIsConfirmOpen(true); + }; + + const handleConfirmClose = () => { + if (!pendingAction) { + console.error("No pending action when confirming close"); + return; + } + + setIsConfirmOpen(false); + + try { + // Always close the dialog first + key.reset(); + setSelectedPermissions([]); + setName(""); + + // Then execute the specific action + switch (pendingAction) { + case "create-another": + // Reset form for creating another key + break; + + case "go-to-details": + router.push("/settings/root-keys"); + break; + + default: + // Dialog already closed, nothing more to do + router.push("/settings/root-keys"); + break; + } + } catch (error) { + console.error("Error executing pending action:", error); + toast.error("Action Failed", { + description: "An unexpected error occurred. Please try again.", + }); + } finally { + setPendingAction(null); + } + }; + + const handleDialogOpenChange = (open: boolean) => { + if (!open) { + handleCloseAttempt("close"); + } + }; + return (
@@ -159,7 +246,7 @@ export const Client: React.FC = ({ apis }) => { label={{category}} description={`Select all permissions for ${category} in this workspace`} checked={isAllSelected} - setChecked={(isChecked) => { + setChecked={(isChecked: boolean) => { allPermissionNames.forEach((permission) => { handleSetChecked(permission, isChecked); }); @@ -174,7 +261,7 @@ export const Client: React.FC = ({ apis }) => { label={action} description={description} checked={selectedPermissions.includes(permission)} - setChecked={(isChecked) => handleSetChecked(permission, isChecked)} + setChecked={(isChecked: boolean) => handleSetChecked(permission, isChecked)} /> ))}
@@ -232,7 +319,7 @@ export const Client: React.FC = ({ apis }) => { } description={`Select all ${category} permissions for this API`} checked={isAllSelected} - setChecked={(isChecked) => { + setChecked={(isChecked: boolean) => { allPermissionNames.forEach((permission) => { handleSetChecked(permission, isChecked); }); @@ -247,7 +334,9 @@ export const Client: React.FC = ({ apis }) => { label={action} description={description} checked={selectedPermissions.includes(permission)} - setChecked={(isChecked) => handleSetChecked(permission, isChecked)} + setChecked={(isChecked: boolean) => + handleSetChecked(permission, isChecked) + } /> ))} @@ -271,93 +360,140 @@ export const Client: React.FC = ({ apis }) => { Create New Key - { - if (!v) { - // Remove the key from memory when closing the modal - key.reset(); - setSelectedPermissions([]); - setName(""); - router.push("/settings/root-keys"); - } - }} - > - - - Your API Key - - This key is only shown once and can not be recovered. Please store it somewhere safe. - - - - {showKey ? key.data?.key : maskedKey} -
- + handleCloseAttempt("close")} + > + <> +
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+ Root Key Created +
+
+ You've successfully generated a new Root key. +
+
+
+
+
+
+
Key Details
+
+
+
+ +
+
+
{key.data?.keyId}
+ +
+ {name ?? UNNAMED_KEY} +
+
+
+ +
+
+
+
+
Key Secret
+ - +
+ + + Copy and save this key secret as it won't be shown again.{" "} + + Learn more + + +
+
+
+
Try It Out
+ + } + copyButton={} + > + {showKeyInSnippet ? snippet : snippet.replace(key.data?.key ?? "", maskedKey)} + +
+
+
+ All set! You can now create another key or explore the docs to learn more +
- - - -

- Try creating a new api key for your users: -

- -
- - -
-
- {showKeyInSnippet ? snippet : snippet.replace(key.data?.key ?? "", maskedKey)}
-
- - - + e.preventDefault(), + }} + /> +
); }; - -type PermissionToggleProps = { - checked: boolean; - setChecked: (checked: boolean) => void; - label: string | React.ReactNode; - description: string; -}; - -const PermissionToggle: React.FC = ({ - checked, - setChecked, - label, - description, -}) => { - return ( -
-
- { - setChecked(!checked); - }} - /> - -
- -

{description}

-
- ); -}; diff --git a/apps/dashboard/app/(app)/settings/vercel/page.tsx b/apps/dashboard/app/(app)/settings/vercel/page.tsx index 7a1c3cf096..62399cdc00 100644 --- a/apps/dashboard/app/(app)/settings/vercel/page.tsx +++ b/apps/dashboard/app/(app)/settings/vercel/page.tsx @@ -6,12 +6,11 @@ import { Navbar as SubMenu } from "@/components/dashboard/navbar"; import { Navigation } from "@/components/navigation/navigation"; import { PageContent } from "@/components/page-content"; -import { Code } from "@/components/ui/code"; import { getAuth } from "@/lib/auth"; import { auth } from "@/lib/auth/server"; import { type Api, type Key, type VercelBinding, db, eq, schema } from "@/lib/db"; import { Gear } from "@unkey/icons"; -import { Button, Empty } from "@unkey/ui"; +import { Button, Code, Empty } from "@unkey/ui"; import { Vercel } from "@unkey/vercel"; import Link from "next/link"; import { notFound } from "next/navigation"; diff --git a/apps/dashboard/app/integrations/vercel/callback/page.tsx b/apps/dashboard/app/integrations/vercel/callback/page.tsx index ac6d366f36..a8aea9f74d 100644 --- a/apps/dashboard/app/integrations/vercel/callback/page.tsx +++ b/apps/dashboard/app/integrations/vercel/callback/page.tsx @@ -3,11 +3,10 @@ * Hiding for now until we decide if we want to fix it up or toss it */ -import { Code } from "@/components/ui/code"; import { getAuth } from "@/lib/auth"; import { db, eq, schema } from "@/lib/db"; import { vercelIntegrationEnv } from "@/lib/env"; -import { Empty } from "@unkey/ui"; +import { Code, Empty } from "@unkey/ui"; import { Vercel } from "@unkey/vercel"; import { Client } from "./client"; import { exchangeCode } from "./exchange-code"; diff --git a/apps/dashboard/app/new/create-ratelimit.tsx b/apps/dashboard/app/new/create-ratelimit.tsx index 3b07b642db..cfdb818527 100644 --- a/apps/dashboard/app/new/create-ratelimit.tsx +++ b/apps/dashboard/app/new/create-ratelimit.tsx @@ -1,9 +1,8 @@ -import { Code } from "@/components/ui/code"; import { getCurrentUser } from "@/lib/auth"; import { router } from "@/lib/trpc/routers"; import { createCallerFactory } from "@trpc/server"; import type { Workspace } from "@unkey/db"; -import { Button, CopyButton } from "@unkey/ui"; +import { Button, Code, CopyButton } from "@unkey/ui"; import { GlobeLock } from "lucide-react"; import Link from "next/link"; @@ -84,9 +83,11 @@ export const CreateRatelimit: React.FC = async (props) => { The following request will limit the user to 10 requests per 10 seconds.

- + } + > {snippet} - diff --git a/apps/dashboard/app/new/keys.tsx b/apps/dashboard/app/new/keys.tsx index f15cebccd3..4556196bcf 100644 --- a/apps/dashboard/app/new/keys.tsx +++ b/apps/dashboard/app/new/keys.tsx @@ -1,7 +1,6 @@ "use client"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Code } from "@/components/ui/code"; import { trpc } from "@/lib/trpc/client"; import { Button, @@ -11,6 +10,7 @@ import { CardFooter, CardHeader, CardTitle, + Code, CopyButton, Empty, Separator, diff --git a/apps/dashboard/components/navigation/copyable-id-button.tsx b/apps/dashboard/components/navigation/copyable-id-button.tsx index 838771aefc..b5bf7096ef 100644 --- a/apps/dashboard/components/navigation/copyable-id-button.tsx +++ b/apps/dashboard/components/navigation/copyable-id-button.tsx @@ -69,6 +69,7 @@ export const CopyableIDButton = ({ value, className = "" }: CopyableIDButtonProp {value} { const apiKey = "uk_1234567890abcdef"; + return ( -
-
- Basic usage: - +
+
+
+ Basic usage: + +
+
+ {apiKey} + +
-
- {apiKey} - + +
+
+ Different variants: + + + +
+ +
+ With custom styling: + +
diff --git a/apps/engineering/content/design/components/buttons/copy-button.mdx b/apps/engineering/content/design/components/buttons/copy-button.mdx index e710e1e13e..9ce262c55d 100644 --- a/apps/engineering/content/design/components/buttons/copy-button.mdx +++ b/apps/engineering/content/design/components/buttons/copy-button.mdx @@ -1,38 +1,67 @@ --- title: CopyButton -summary: A button component that copies text to clipboard with visual feedback +summary: A button component that copies text to clipboard with visual feedback and accessibility features --- import { Default } from "./copy-button.examples" -The `CopyButton` component provides a simple way to copy text to the clipboard with visual feedback. When clicked, it copies the provided text and shows a checkmark icon to indicate success. +The `CopyButton` component provides a simple way to copy text to the clipboard with visual feedback. It extends the base Button component and includes built-in copy functionality with proper accessibility support. ## Features -- Visual feedback with icon change on copy -- Accessible with screen reader support +- Visual feedback with icon change on copy (TaskUnchecked → TaskChecked) +- Accessible with screen reader support and ARIA labels - Automatic reset after 2 seconds -- Customizable through className prop -- TypeScript support +- Extends ButtonProps for full button customization +- Analytics support with optional src prop +- Prevents event propagation to parent elements +- TypeScript support with proper typing ## Props -| Prop | Type | Description | -|------|------|-------------| -| `value` | `string` | The text content to be copied to clipboard | -| `src` | `string?` | Optional source identifier for analytics | -| `className` | `string?` | Additional CSS classes to apply to the button | +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `value` | `string` | **Required** | The text content to be copied to clipboard | +| `src` | `string?` | `undefined` | Optional source identifier for analytics tracking | +| `variant` | `ButtonVariant` | `"outline"` | Button variant (outline, ghost, primary, etc.) | +| `className` | `string?` | `undefined` | Additional CSS classes to apply to the button | + +The component also accepts all standard Button props. ## Usage +## Behavior + +1. **Click Handling**: On click, the button copies the provided text to the clipboard +2. **Visual Feedback**: The icon changes from TaskUnchecked to TaskChecked state +3. **Auto Reset**: After 2 seconds, the icon reverts to its original state +4. **Event Prevention**: Parent click events are prevented from triggering (`e.stopPropagation`) +5. **Analytics**: If provided, the `src` prop is passed to analytics for tracking + ## Accessibility -The component includes screen reader support with an appropriate "Copy" label. The visual state change (unchecked to checked icon) provides clear feedback for all users. +The component includes comprehensive accessibility features: -## Behavior +- **ARIA Label**: `aria-label="Copy to clipboard"` for screen readers +- **Screen Reader Text**: Hidden "Copy" text for additional context +- **Title Attribute**: `title="Copy to clipboard"` for tooltip on hover +- **Focus Management**: Proper focus states with `focus:ring-0 focus:border-grayA-6` +- **Keyboard Support**: Full keyboard navigation support inherited from Button component + +## Styling + +The component includes default styling: + +- Fixed dimensions: `w-6 h-6` (24x24px) +- Focus states: `focus:ring-0 focus:border-grayA-6` +- Icons: Full-size icons with `w-full h-full` +- Default variant: `outline` for subtle appearance + +## Best Practices -1. On click, the button copies the provided text to the clipboard -2. The icon changes from an unchecked to checked state -3. After 2 seconds, the icon reverts to its original state -4. Parent click events are prevented from triggering (e.stopPropagation) +- Use the `src` prop for analytics tracking when copying sensitive data +- Provide meaningful `value` content for better user experience +- Consider the button's context when choosing variants +- Ensure sufficient contrast for the button in your design +- Test with screen readers to verify accessibility diff --git a/apps/engineering/content/design/components/code.example.tsx b/apps/engineering/content/design/components/code.example.tsx new file mode 100644 index 0000000000..848d40d98f --- /dev/null +++ b/apps/engineering/content/design/components/code.example.tsx @@ -0,0 +1,62 @@ +"use client"; +import { RenderComponentWithSnippet } from "@/app/components/render"; +import { Code, CopyButton, VisibleButton } from "@unkey/ui"; +import { useState } from "react"; + +const EXAMPLE_SNIPPET = `curl -XPOST 'https://api.unkey.dev/v1/ratelimits.limit' \\ + -H 'Content-Type: application/json' \\ + -H 'Authorization: Bearer ' \\ + -d '{ + "namespace": "demo_namespace", + "identifier": "user_123", + "limit": 10, + "duration": 10000 + }'`; + +export function CodeExample() { + const [showKeyInSnippet, setShowKeyInSnippet] = useState(false); + + return ( + +
+
+ + } + visibleButton={ + setShowKeyInSnippet(visible)} + title="Key Snippet" + /> + } + > + {showKeyInSnippet + ? EXAMPLE_SNIPPET + : EXAMPLE_SNIPPET.replace("", "********")} + +
+
+
+ ); +} + +export function CodeVariants() { + return ( + +
+
+
+ Default Variant + Ghost Variant + Legacy Variant +
+
+
+
+ ); +} diff --git a/apps/engineering/content/design/components/code.mdx b/apps/engineering/content/design/components/code.mdx new file mode 100644 index 0000000000..82eeb26688 --- /dev/null +++ b/apps/engineering/content/design/components/code.mdx @@ -0,0 +1,116 @@ +--- +title: Code +description: A versatile code component for displaying inline and block code snippets with customizable styling and integrated buttons. +--- + +import { CodeExample, CodeVariants } from "./code.example"; + +# Code + +The Code component provides a consistent way to display code snippets within your application. It supports both inline and block code display with customizable styling options and integrated button functionality. + +## Features + +- Multiple variants (default, ghost, legacy) +- Integrated copy and visibility buttons +- Consistent monospace font +- Customizable styling +- Accessible by default +- Responsive design +- Focus states for better interaction + +## Usage + +```tsx +import { Code, CopyButton, VisibleButton } from "@unkey/ui"; + +function MyComponent() { + return ( + } + visibleButton={ {}} />} + > + const hello = "world"; + + ); +} +``` + +## Examples + +### Default Variant with Buttons + +The default variant provides a subtle background with a border and integrated buttons. + + + +### Variant Styles + +The Code component supports different visual styles. + + + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `variant` | `"default" \| "ghost" \| "legacy"` | `"default"` | The visual style of the code component | +| `className` | `string` | `undefined` | Additional CSS classes for the wrapper div | +| `buttonsClassName` | `string` | `undefined` | Additional CSS classes for the buttons container | +| `preClassName` | `string` | `undefined` | Additional CSS classes for the pre element | +| `copyButton` | `React.ReactNode` | `undefined` | Copy button component to display | +| `visibleButton` | `React.ReactNode` | `undefined` | Visibility toggle button component | + +The component also accepts all standard HTML pre element props. + +## Variants + +### Default +- Subtle background with border +- White background in light mode, black in dark mode +- Gray border for visual separation + +### Ghost +- Transparent background +- No border +- Minimal visual impact + +### Legacy +- Primary text color +- Subtle background +- Hover effects with primary border +- Rounded corners + +## Styling + +The Code component includes: + +- Monospace font for code readability +- Consistent padding and border radius +- Focus states for keyboard navigation +- Hover effects for better interaction +- Dark mode support +- Responsive design +- Flexible button positioning + +## Best Practices + +- Use the default variant for code snippets with buttons +- Use the ghost variant when you need minimal visual impact +- Use the legacy variant for backward compatibility +- Add appropriate padding around the code component +- Consider using syntax highlighting for complex code blocks +- Ensure sufficient contrast between code and background +- Use semantic HTML structure for better accessibility +- Position buttons appropriately using the buttonsClassName prop + +## Accessibility + +The Code component is built with accessibility in mind: + +- Proper ARIA attributes +- Keyboard focus management +- High contrast text +- Semantic HTML structure +- Screen reader support +- Focus states for interactive elements \ No newline at end of file diff --git a/internal/ui/src/components/code.tsx b/internal/ui/src/components/code.tsx new file mode 100644 index 0000000000..332eec6e63 --- /dev/null +++ b/internal/ui/src/components/code.tsx @@ -0,0 +1,68 @@ +"use client"; +import { type VariantProps, cva } from "class-variance-authority"; +import type * as React from "react"; +import { cn } from "../lib/utils"; + +const codeVariants = cva( + "flex w-full px-4 border rounded-xl ring-0 flex items-start justify-between ph-no-capture whitespace-pre-wrap break-all ", + { + variants: { + variant: { + default: + "border-grayA-5 focus:outline-none focus:ring-0 bg-white dark:bg-black text-[11px] py-2", + ghost: "border-none bg-transparent text-[11px] py-2", + legacy: + "text-primary bg-background-subtle rounded-md hover:border-primary focus:outline-none focus:ring-0 border-grayA-4", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface CodeProps + extends React.HTMLAttributes, + VariantProps { + className?: string; + buttonsClassName?: string; + preClassName?: string; + variant?: "default" | "ghost" | "legacy"; + copyButton?: React.ReactNode; + visibleButton?: React.ReactNode; +} + +function Code({ + className, + variant, + copyButton, + visibleButton, + buttonsClassName, + preClassName, + ...props +}: CodeProps) { + return ( +
+
+      
+ {visibleButton} + {copyButton} +
+
+ ); +} + +Code.displayName = "Code"; + +export { Code, codeVariants }; diff --git a/internal/ui/src/components/copy-button.tsx b/internal/ui/src/components/copy-button.tsx index 4f0a456a72..381ad7487a 100644 --- a/internal/ui/src/components/copy-button.tsx +++ b/internal/ui/src/components/copy-button.tsx @@ -2,18 +2,25 @@ import { TaskChecked, TaskUnchecked } from "@unkey/icons"; import * as React from "react"; import { cn } from "../lib/utils"; +import { Button, type ButtonProps } from "./button"; -interface CopyButtonProps extends React.HTMLAttributes { +type CopyButtonProps = ButtonProps & { + /** + * The value to copy to clipboard + */ value: string; + /** + * Source component for analytics + */ src?: string; -} +}; async function copyToClipboardWithMeta(value: string, _meta?: Record) { navigator.clipboard.writeText(value); } export const CopyButton = React.forwardRef( - ({ value, className, src, ...props }, ref) => { + ({ value, src, variant = "outline", className, onClick, ...props }, ref) => { const [copied, setCopied] = React.useState(false); React.useEffect(() => { @@ -27,18 +34,24 @@ export const CopyButton = React.forwardRef( }, [copied]); return ( - + ); }, ); diff --git a/internal/ui/src/index.ts b/internal/ui/src/index.ts index 1ac2df9016..675bbf53b3 100644 --- a/internal/ui/src/index.ts +++ b/internal/ui/src/index.ts @@ -1,5 +1,6 @@ export * from "./components/button"; export * from "./components/card"; +export * from "./components/code"; export * from "./components/copy-button"; export * from "./components/date-time/date-time"; export * from "./components/dialog/dialog-container";