使用 TailwindCSS 和 Radix Dialog 建構自託管提示詞庫介面
Building a Self-Hosted Prompt Library UI with TailwindCSS and Radix Dialog
文章説明如何使用 TailwindCSS 和 Radix Dialog 建構自託管提示詞庫介面,處理提示詞預覽視窗的無障礙操作、token 數量提示及大量提示詞的版面效能。Radix Dialog 取代手動操作 DOM 的彈出視窗;渲染 2,000+ 個提示詞時,加入 CSS containment 將版面重新計算時間由 300ms 降至 45ms。
Building a Self-Hosted Prompt Library UI with TailwindCSS and Radix Dialog
When evaluating f/prompts.chat for internal deployment, the first friction point wasn't the backend—it was the UI layer for browsing, searching, and injecting prompts into existing AI workflows. The repository offers a clean prompt-sharing architecture, but integrating it into a production developer portal requires solving three specific UI problems:
- Accessible modal overlays for prompt preview without disrupting screen reader navigation
- Token-aware textarea components that provide visual feedback when prompt length approaches model limits
- CSS containment strategies to prevent layout thrashing when rendering thousands of prompts with dynamic Markdown content
The Broken Pattern: Direct DOM Manipulation for Modals
Our initial prototype used a naive approach—toggling display: none on a modal wrapper and manually managing focus with document.querySelector. This broke immediately:
// Anti-pattern: breaks screen reader focus trapping
const showPrompt = (id: string) => {
document.getElementById('modal').style.display = 'block';
document.getElementById('modal-content').focus(); // ❌ Lost context
};
Root cause: No focus restoration on close, no aria-modal="true" attribute, and animations triggered layout recalculations on every frame due to animating width/height directly.
Production Fix: Radix Dialog with TailwindCSS Transitions
Switching to Radix Primitives solved the accessibility contract while keeping TailwindCSS for styling:
import * as Dialog from '@radix-ui/react-dialog';
import { useEffect, useRef } from 'react';
interface PromptModalProps {
prompt: { id: string; title: string; content: string; tokens?: number };
open: boolean;
onClose: () => void;
}
export function PromptModal({ prompt, open, onClose }: PromptModalProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (open && textareaRef.current) {
textareaRef.current.select();
}
}, [open]);
return (
<Dialog.Root open={open} onOpenChange={onClose}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<Dialog.Content
className="fixed left-[50%] top-[50%] z-50 w-full max-w-2xl translate-x-[-50%] translate-y-[-50%] rounded-lg border border-gray-200 bg-white p-6 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]"
aria-describedby="prompt-description"
>
<Dialog.Title className="text-xl font-semibold text-gray-900">
{prompt.title}
</Dialog.Title>
<Dialog.Description id="prompt-description" className="sr-only">
Prompt content for copying into your AI workflow
</Dialog.Description>
<TokenAwareTextarea
ref={textareaRef}
value={prompt.content}
maxTokens={prompt.tokens}
className="mt-4"
/>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => {
navigator.clipboard.writeText(prompt.content);
}}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Copy to Clipboard
</button>
<Dialog.Close asChild>
<button className="rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
Close
</button>
</Dialog.Close>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
Token-Aware Textarea with Visual Feedback
When integrating with AI gateways (we route through https://b-lost.com/v1 for multi-provider fallback), token limits vary by model. The textarea needs to warn users before they hit the ceiling:
import { forwardRef, useMemo } from 'react';
import { encode } from 'gpt-tokenizer'; // or tiktoken
interface TokenAwareTextareaProps {
value: string;
maxTokens?: number;
className?: string;
}
export const TokenAwareTextarea = forwardRef<
HTMLTextAreaElement,
TokenAwareTextareaProps
>(({ value, maxTokens = 4096, className = '' }, ref) => {
const tokenCount = useMemo(() => encode(value).length, [value]);
const isNearLimit = tokenCount > maxTokens * 0.9;
const isOverLimit = tokenCount > maxTokens;
return (
<div className={className}>
<textarea
ref={ref}
value={value}
readOnly
rows={12}
className={`w-full rounded border p-3 font-mono text-sm focus:outline-none focus:ring-2 ${
isOverLimit
? 'border-red-500 bg-red-50 focus:ring-red-500'
: isNearLimit
? 'border-yellow-500 bg-yellow-50 focus:ring-yellow-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
aria-describedby="token-count"
/>
<div
id="token-count"
className={`mt-1 text-right text-xs ${
isOverLimit ? 'text-red-600' : isNearLimit ? 'text-yellow-600' : 'text-gray-500'
}`}
>
{tokenCount} / {maxTokens} tokens
{isOverLimit && ' — exceeds model limit'}
</div>
</div>
);
});
TokenAwareTextarea.displayName = 'TokenAwareTextarea';
CSS Containment for Prompt List Performance
Rendering 2,000+ prompts with Markdown preview caused 300ms layout recalculations. Adding contain: layout style to each card container dropped it to 45ms:
/* Tailwind arbitrary property */
.prompt-card {
@apply rounded-lg border border-gray-200 p-4;
contain: layout style;
}
For dynamic height cards with auto-rows-auto grid, use content-visibility: auto instead:
<div className="grid auto-rows-auto gap-4 md:grid-cols-2 lg:grid-cols-3">
{prompts.map((p) => (
<article
key={p.id}
className="prompt-card [content-visibility:auto]"
style={{ contentVisibilityAutoStateCallback: true } as any}
>
{/* Card content */}
</article>
))}
</div>
The Remaining Question: State Synchronization
f/prompts.chat's architecture assumes prompts are relatively static. But when integrating with live AI sessions, you need bidirectional sync—users might annotate prompts with execution results or pin favorites. Do you:
- Fork the prompt object into session-local state and accept drift?
- Extend the backend schema with user-specific metadata (breaking self-hosted simplicity)?
- Store deltas in
localStorageand accept that shared devices lose context?
Each choice trades off data consistency, deployment complexity, and user experience. What's your approach when the upstream data model doesn't match your runtime needs?
來源:DEV Community · dev.to