Files
supabase/apps/docs/features/ui/PromptPanel.tsx
Danny White e74ccefbb9 fix(docs): add cursor-pointer to tabs and copy controls (#48380)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Bug fix / polish

## What is the current behavior?

Some docs interactive controls (copy buttons, tab triggers) do not show
a pointer cursor on hover, so they feel less clickable than surrounding
links. Called out on https://github.com/supabase/supabase/pull/48318.

## What is the new behavior?

Adds `cursor-pointer` in one sweep so related controls stay consistent:

- Shared `TabsTrigger` in `packages/ui` (covers PromptPanel AI Prompt /
CLI tabs, including the unselected tab)
- PromptPanel copy button and Show more / Show less
- Guides sidebar “Copy as Markdown”
- Docs code block copy button

## To test

Hover the controls below and confirm the cursor is `pointer` on both
selected and unselected tabs, and on copy buttons.

### Docs
Preview:
https://docs-git-dnywh-docs-cursor-pointer-supabase.vercel.app/docs

- Homepage setup prompt: [AI Prompt / CLI tabs +
copy](https://docs-git-dnywh-docs-cursor-pointer-supabase.vercel.app/docs)
- Quickstart prompt: [Show more / Show less +
copy](https://docs-git-dnywh-docs-cursor-pointer-supabase.vercel.app/docs/guides/getting-started/quickstarts/nextjs)
- Guide sidebar + code block: [Copy as Markdown + code
copy](https://docs-git-dnywh-docs-cursor-pointer-supabase.vercel.app/docs/guides/database/tables)

### Design system
Preview:
https://design-system-git-dnywh-docs-cursor-pointer-supabase.vercel.app/design-system

- Shared tabs demo: [Account / Password
triggers](https://design-system-git-dnywh-docs-cursor-pointer-supabase.vercel.app/design-system/docs/components/tabs)

### UI library
Preview:
https://ui-library-git-dnywh-docs-cursor-pointer-supabase.vercel.app/ui

- Install command package-manager tabs (npm / pnpm / yarn / bun) + copy:
[Password-based
auth](https://ui-library-git-dnywh-docs-cursor-pointer-supabase.vercel.app/ui/docs/nextjs/password-based-auth)

### Studio (staging)
Preview:
https://studio-staging-git-dnywh-docs-cursor-pointer-supabase.vercel.app

- Auth user panel: open a project → Authentication → Users → select a
user → hover Overview / Logs (and related) tabs
- Connect sheet: open Connect on a project → hover the install method
tabs

### Studio (self-hosted)
Preview:
https://studio-self-hosted-git-dnywh-docs-cursor-pointer-supabase.vercel.app

- Same `TabsTrigger` callsites as Studio staging (user panel / Connect
sheet)

### WWW
Preview:
https://zone-www-dot-com-git-dnywh-docs-cursor-pointer-supabase.vercel.app

- Blog chart tabs (`PGChart` → shared `TabsTrigger`): [Latency / Number
of results / Average latency / Raw
data](https://zone-www-dot-com-git-dnywh-docs-cursor-pointer-supabase.vercel.app/blog/postgres-full-text-search-vs-the-rest)
(scroll to the Results section)

## Additional context

Split out from #48318 so the homepage prompt polish stays focused.
Prefer fixing the shared tab trigger rather than only the PromptPanel
copy button, otherwise the copy control would show pointer while an
unselected CLI tab would not.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Style**
* Improved hover feedback across the docs UI by adding a pointer cursor
to “Copy as Markdown,” code block copy and word-wrap controls, prompt
copy buttons, and tab selectors.
* Updated cursor styling consistently so interactive controls better
communicate clickability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 10:28:01 +10:00

331 lines
9.4 KiB
TypeScript

'use client'
import { Check, Copy } from 'lucide-react'
import {
Children,
isValidElement,
useEffect,
useId,
useState,
type ReactElement,
type ReactNode,
} from 'react'
// shadcn tabs from packages/ui/src/components/shadcn/ui/tabs.tsx
import { cn, copyToClipboard, Tabs, TabsContent, TabsList, TabsTrigger } from 'ui'
type PromptTitleProps = {
children: ReactNode
icon?: ReactNode
}
type PromptCopyProps = {
children: string
}
type PromptContentProps = {
children: ReactNode
/** Draws attention with a text shimmer. Defaults to true. */
shimmer?: boolean
}
type PromptProps = {
children: ReactNode
/** Stable tab id. Auto-generated when omitted. */
value?: string
/** When true, content starts collapsed with a show more / show less control. */
expandable?: boolean
}
type PromptPanelProps = {
children: ReactNode
className?: string
}
type CollectedPrompt = {
value: string
title: ReactNode
icon?: ReactNode
copyValue: string
content: ReactNode
expandable: boolean
shimmer: boolean
}
function PromptTitle(_props: PromptTitleProps) {
return null
}
PromptTitle.displayName = 'PromptTitle'
function PromptCopy(_props: PromptCopyProps) {
return null
}
PromptCopy.displayName = 'PromptCopy'
function PromptContent(_props: PromptContentProps) {
return null
}
PromptContent.displayName = 'PromptContent'
function Prompt(_props: PromptProps) {
return null
}
Prompt.displayName = 'Prompt'
function isElementOfType(child: ReactNode, type: { displayName?: string }) {
return (
isValidElement(child) &&
typeof child.type !== 'string' &&
'displayName' in child.type &&
child.type.displayName === type.displayName
)
}
function collectPrompt(prompt: ReactElement<PromptProps>, index: number): CollectedPrompt {
let title: ReactNode = `Prompt ${index + 1}`
let icon: ReactNode | undefined
let copyValue = ''
let content: ReactNode = null
let shimmer = true
Children.forEach(prompt.props.children, (child) => {
if (isElementOfType(child, PromptTitle)) {
const props = (child as ReactElement<PromptTitleProps>).props
title = props.children
icon = props.icon
} else if (isElementOfType(child, PromptCopy)) {
copyValue = (child as ReactElement<PromptCopyProps>).props.children
} else if (isElementOfType(child, PromptContent)) {
const props = (child as ReactElement<PromptContentProps>).props
content = props.children
shimmer = props.shimmer !== false
}
})
return {
value: prompt.props.value ?? `prompt-${index}`,
title,
icon,
copyValue,
content,
expandable: Boolean(prompt.props.expandable),
shimmer,
}
}
function collectPrompts(children: ReactNode): CollectedPrompt[] {
return Children.toArray(children)
.filter((child): child is ReactElement<PromptProps> => isElementOfType(child, Prompt))
.map((prompt, index) => collectPrompt(prompt, index))
}
function ExpandableContent({ children }: { children: ReactNode }) {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div>
<div className="relative">
<div className={cn(!isExpanded && 'max-h-28 overflow-hidden')}>{children}</div>
{!isExpanded && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-background to-transparent" />
)}
</div>
<button
tabIndex={0}
type="button"
onClick={() => setIsExpanded((expanded) => !expanded)}
className="mt-2 cursor-pointer text-sm text-brand-link transition-colors hover:text-foreground focus-ring"
aria-expanded={isExpanded}
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
</div>
)
}
function PromptBody({
prompt,
shimmerEnabled,
}: {
prompt: CollectedPrompt
shimmerEnabled: boolean
}) {
const [shimmerMounted, setShimmerMounted] = useState(prompt.shimmer)
const content = (
<div
className={cn(shimmerMounted && 'shimmer')}
data-shimmer-fading={shimmerMounted && !shimmerEnabled ? true : undefined}
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget) return
if (shimmerMounted && !shimmerEnabled) {
setShimmerMounted(false)
}
}}
>
{prompt.content}
</div>
)
return (
<div className="px-4 py-3.5 text-sm leading-6 text-foreground-light font-normal">
{prompt.expandable ? <ExpandableContent>{content}</ExpandableContent> : content}
</div>
)
}
function CopyButton({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!copied) return
const timeout = window.setTimeout(() => setCopied(false), 2000)
return () => window.clearTimeout(timeout)
}, [copied])
return (
<button
tabIndex={0}
type="button"
onClick={() => {
copyToClipboard(value, () => {
setCopied(true)
})
}}
className="cursor-pointer rounded-sm p-1.5 text-foreground-muted transition-colors hover:bg-surface-200 hover:text-foreground focus-ring"
aria-label={copied ? `${label} copied` : `Copy ${label}`}
title={copied ? 'Copied' : 'Copy to clipboard'}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
)
}
function TabLabel({ icon, children }: { icon?: ReactNode; children: ReactNode }) {
return (
<span className="inline-flex items-center gap-1.5">
{icon && (
<span aria-hidden="true" className="text-tertiary-foreground [&_svg]:size-3.5">
{icon}
</span>
)}
{children}
</span>
)
}
const tabTriggerClassName = 'h-full px-0 py-0 text-xs shadow-none data-[state=active]:shadow-none'
/**
* Copyable prompt card. Compose one or more `<Prompt>` children; multiple
* prompts render as tabs. A single prompt still shows the header + copy
* button, with the title styled like an inactive tab (no underline).
*
* @example
* ```tsx
* <PromptPanel>
* <Prompt value="prompt" expandable>
* <PromptTitle icon={<Sparkles />}>AI Prompt</PromptTitle>
* <PromptCopy>Plain text copied to the clipboard</PromptCopy>
* <PromptContent>Rich content shown in the panel</PromptContent>
* </Prompt>
* </PromptPanel>
* ```
*/
function PromptPanel({ children, className }: PromptPanelProps) {
const fallbackId = useId()
const titleId = useId()
const prompts = collectPrompts(children)
const [activeTab, setActiveTab] = useState(prompts[0]?.value ?? fallbackId)
const [shimmerEnabled, setShimmerEnabled] = useState(true)
if (prompts.length === 0) return null
const activePrompt = prompts.find((prompt) => prompt.value === activeTab) ?? prompts[0]
const hasTabs = prompts.length > 1
const dismissShimmer = () => setShimmerEnabled(false)
const header = (
<div className="flex h-11 items-center justify-between border-b bg-surface-75 px-4">
{hasTabs ? (
<TabsList className="h-full gap-5 border-0">
{prompts.map((prompt) => (
<TabsTrigger key={prompt.value} value={prompt.value} className={tabTriggerClassName}>
<TabLabel icon={prompt.icon}>{prompt.title}</TabLabel>
</TabsTrigger>
))}
</TabsList>
) : (
<span
id={titleId}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap border-b-2 border-transparent text-foreground-lighter',
tabTriggerClassName
)}
>
<TabLabel icon={prompts[0].icon}>{prompts[0].title}</TabLabel>
</span>
)}
<CopyButton
key={activePrompt.value}
label={typeof activePrompt.title === 'string' ? activePrompt.title : 'content'}
value={activePrompt.copyValue}
/>
</div>
)
if (!hasTabs) {
return (
<div
role="region"
aria-labelledby={titleId}
onFocusCapture={dismissShimmer}
onPointerEnter={dismissShimmer}
className={cn(
'w-full overflow-hidden rounded-lg border bg-background shadow-sm',
className
)}
>
{header}
<PromptBody prompt={prompts[0]} shimmerEnabled={shimmerEnabled} />
</div>
)
}
return (
<Tabs
value={activeTab}
onValueChange={setActiveTab}
// Manual activation keeps VoiceOver focus from switching tabs before Copy is reached.
activationMode="manual"
onFocusCapture={dismissShimmer}
onPointerEnter={dismissShimmer}
className={cn('w-full overflow-hidden rounded-lg border bg-background shadow-sm', className)}
>
{header}
{/* Stack panes in one grid cell so the panel keeps the tallest tab's height. */}
<div className="grid">
{prompts.map((prompt) => {
const isActive = prompt.value === activeTab
return (
<TabsContent
key={prompt.value}
value={prompt.value}
forceMount
inert={!isActive}
aria-hidden={!isActive}
className="col-start-1 row-start-1 m-0 data-[state=inactive]:invisible data-[state=inactive]:pointer-events-none"
>
<PromptBody prompt={prompt} shimmerEnabled={shimmerEnabled} />
</TabsContent>
)
})}
</div>
</Tabs>
)
}
export { Prompt, PromptContent, PromptCopy, PromptPanel, PromptTitle }
export type { PromptContentProps, PromptCopyProps, PromptPanelProps, PromptProps, PromptTitleProps }