Product
Playground parameters
API parameter panel with grouped controls, reset actions, a changed count, and scroll indicators.
$ npx context-ds add playground-parametersUsage
Use it for
Request options beside an API playground or any dense settings panel next to a running task.
Avoid
- Bordered fields inside the panel
- Closing the panel while a request runs
- An animated open or close
Dependencies: input, popover, select, tooltip, lucide-react
Example
examples/playground-parameters.tsx
"use client";import * as React from "react";import { ParameterGroup, ParameterInput, ParameterRow, ParametersButton, ParameterSelect, ParameterUnitInput, PlaygroundParameters } from "@/components/product/playground-parameters";import { Switch } from "@/components/ui/switch";const DEFAULTS = { format: "markdown", mainOnly: true, maxAge: "0", wait: "", include: "" };export function Product() { const [open, setOpen] = React.useState(true); const [values, setValues] = React.useState(DEFAULTS); const set = <K extends keyof typeof DEFAULTS>(key: K, value: (typeof DEFAULTS)[K]) => setValues((v) => ({ ...v, [key]: value })); const changed = (Object.keys(DEFAULTS) as (keyof typeof DEFAULTS)[]).filter((k) => values[k] !== DEFAULTS[k]).length; return ( <div className="flex w-full max-w-xl flex-col items-end gap-3"> <ParametersButton open={open} onToggle={() => setOpen((o) => !o)} modifiedCount={changed} /> <PlaygroundParameters open={open} onOpenChange={setOpen} modifiedCount={changed} onReset={() => setValues(DEFAULTS)} className="h-120"> <ParameterGroup title="Output"> <ParameterRow label="Format" description="What the page comes back as."> <ParameterSelect label="Format" value={values.format} onValueChange={(v) => set("format", v)} options={[ { value: "markdown", label: "Markdown" }, { value: "html", label: "HTML" }, { value: "text", label: "Plain text" }, ]} /> </ParameterRow> <ParameterRow label="Main content only" description="Drops navigation, footers and cookie banners." onReset={values.mainOnly === DEFAULTS.mainOnly ? undefined : () => set("mainOnly", DEFAULTS.mainOnly)}> <Switch checked={values.mainOnly} onCheckedChange={(v) => set("mainOnly", v)} aria-label="Main content only" /> </ParameterRow> </ParameterGroup> <ParameterGroup title="Fetching" count={3}> <ParameterRow label="Max age" description="Serve a cached copy up to this old." help="0 always fetches the live page. Cached responses cost no credits."> <ParameterUnitInput unit="sec" value={values.maxAge} min={0} onChange={(e) => set("maxAge", e.target.value)} aria-label="Max age in seconds" /> </ParameterRow> <ParameterRow label="Browser wait" description="How long to let scripts run before reading the page."> <ParameterUnitInput unit="ms" value={values.wait} placeholder="0" min={0} onChange={(e) => set("wait", e.target.value)} aria-label="Browser wait in milliseconds" /> </ParameterRow> <ParameterRow label="Include tags" description="Only keep these elements, comma separated." stacked> <ParameterInput className="w-full font-mono" placeholder="article, main" value={values.include} onChange={(e) => set("include", e.target.value)} aria-label="Include tags" /> </ParameterRow> </ParameterGroup> </PlaygroundParameters> </div> );}export function Fetching() { const [open, setOpen] = React.useState(true); return ( <div className="grid w-full max-w-xl gap-3"><ParametersButton open={open} onToggle={() => setOpen((value) => !value)} modifiedCount={0} /> <PlaygroundParameters open={open} onOpenChange={setOpen} modifiedCount={0} fetching> <ParameterGroup title="Output"> <ParameterRow label="Main content only" description="Drops navigation, footers and cookie banners."> <Switch defaultChecked aria-label="Main content only" /> </ParameterRow> </ParameterGroup> </PlaygroundParameters></div> );}Source
components/product/playground-parameters.tsx
"use client";import * as React from "react";import { ChevronDown, Info, RotateCcw, SlidersHorizontal, X } from "lucide-react";import { Input } from "@/components/ui/input";import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";import { cn } from "@/lib/utils";// The playground's parameters: a floating panel (title, a count of what changed, Reset and close) whose groups of// parameters scroll under soft fades, with a "More" pill while there is more below. Each group is one soft gray box// ruled between rows; each row is the name at the left with its control at the right (a switch, a select, a number),// the description under it at full width, and free-text fields below that. While a request runs the fields go inert// and say so on hover, rather than the panel closing and the page shifting. It appears and goes at once; Escape closes// it unless a menu or dialog is open over the page.const FIELD = "h-8 rounded-control border-0 bg-wash-control px-3 text-body-sm text-fg shadow-none outline-none transition-colors duration-150 hover:bg-wash-selected focus-visible:shadow-none focus-visible:ring-2 focus-visible:ring-focus-ring";const ROW_ICON_BUTTON = "flex size-4 shrink-0 items-center justify-center rounded-tag text-fg-subtle outline-none transition-colors duration-150 hover:text-fg focus-visible:ring-2 focus-visible:ring-focus-ring";// Long option labels wrap inside the narrow panel instead of pushing the control past its edge.const CONTROL = "min-w-0 [&>*]:max-w-full";function isOverlayOpen() { return Array.from(document.querySelectorAll('[role="dialog"]:not([data-slot="playground-parameters"]), [role="menu"], [role="listbox"]')).some((layer) => !layer.closest('[inert], [aria-hidden="true"]'));}/** The count of changed parameters: a small brand dot that rides beside "Parameters". */function ParametersChangedCount({ count }: { count: number }) { return <span className="grid size-4 shrink-0 place-items-center rounded-pill bg-brand text-counter text-on-brand tabular-nums">{count}</span>;}/** The composer's toggle: pressed while the panel is open, with the changed count riding along so edits show when it's closed. */function ParametersButton({ open, onToggle, modifiedCount = 0, className }: { open: boolean; onToggle: () => void; modifiedCount?: number; className?: string }) { return ( <button type="button" onClick={onToggle} aria-pressed={open} aria-label={modifiedCount > 0 ? `Parameters, ${modifiedCount} changed` : "Parameters"} className={cn( "flex h-8 shrink-0 items-center gap-1.5 rounded-control px-3 text-body-sm outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-focus-ring", open ? "bg-wash-control text-fg" : "text-fg-muted hover:bg-wash-hover hover:text-fg", className, )} > <SlidersHorizontal className="size-3.5" strokeWidth={1.5} /> <span className="max-sm:hidden">Parameters</span> {modifiedCount > 0 && <ParametersChangedCount count={modifiedCount} />} </button> );}/** Pointer cover over the fields while a request runs, with a label that follows the cursor. Keyboard is cut off by `inert`. */function FetchingOverlay() { const labelRef = React.useRef<HTMLSpanElement>(null); return ( <div aria-hidden className="absolute inset-0 z-20 cursor-not-allowed" onMouseMove={(e) => { const label = labelRef.current; if (!label) return; const box = e.currentTarget.getBoundingClientRect(); const x = Math.min(e.clientX - box.left + 14, box.width - label.offsetWidth - 4); const y = Math.min(e.clientY - box.top + 14, box.height - label.offsetHeight - 4); label.style.transform = `translate(${Math.max(4, x)}px, ${Math.max(4, y)}px)`; label.style.opacity = "1"; }} onMouseLeave={() => { if (labelRef.current) labelRef.current.style.opacity = "0"; }} > <span ref={labelRef} className="pointer-events-none absolute top-0 left-0 rounded-tag bg-primary px-2 py-1 text-caption whitespace-nowrap text-on-primary opacity-0 transition-opacity duration-150"> Uneditable while fetching </span> </div> );}function ParametersScroller({ fetching, children }: { fetching: boolean; children: React.ReactNode }) { const ref = React.useRef<HTMLDivElement>(null); const [edges, setEdges] = React.useState({ above: false, below: false }); const measure = React.useCallback(() => { const el = ref.current; if (!el) return; const above = el.scrollTop > 1; const below = el.scrollTop + el.clientHeight < el.scrollHeight - 1; setEdges((c) => (c.above === above && c.below === below ? c : { above, below })); }, []); React.useLayoutEffect(() => { const el = ref.current; if (!el) return; measure(); const observer = new ResizeObserver(measure); observer.observe(el); for (const child of el.children) observer.observe(child); return () => observer.disconnect(); }, [measure]); return ( <div className="relative flex min-h-0 flex-1 flex-col"> <div ref={ref} onScroll={measure} inert={fetching || undefined} aria-busy={fetching} className={cn("flex flex-1 flex-col gap-5 overflow-y-auto overscroll-contain px-4 pt-1 pb-4 transition-opacity duration-200 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", fetching && "opacity-50")} > {children} </div> <div aria-hidden className={cn("pointer-events-none absolute inset-x-0 top-0 h-5 bg-linear-to-b from-surface to-transparent transition-opacity duration-200", !edges.above && "opacity-0")} /> <div aria-hidden className={cn("pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-linear-to-t from-surface to-transparent transition-opacity duration-200", !edges.below && "opacity-0")} /> {/* Pointer only: keyboard and screen reader users scroll the list itself. */} <button type="button" tabIndex={-1} aria-hidden onClick={() => ref.current?.scrollBy({ top: ref.current.clientHeight * 0.8, behavior: "smooth" })} className={cn( "absolute bottom-2 left-1/2 flex h-6 -translate-x-1/2 items-center gap-1 rounded-pill bg-surface pr-2 pl-2.5 text-caption text-fg-muted shadow-card transition-[opacity,color,scale] duration-150 ease-out hover:text-fg active:scale-[0.97] motion-reduce:active:scale-100", (!edges.below || fetching) && "pointer-events-none opacity-0", )} > More <ChevronDown className="size-3.5" strokeWidth={1.5} /> </button> {fetching && <FetchingOverlay />} </div> );}/** The panel. Place it yourself (the playground docks it 28px off the window's right edge, 320px wide, level with the title). */function PlaygroundParameters({ title = "Parameters", open, onOpenChange, modifiedCount = 0, onReset, fetching = false, className, children,}: { title?: string; open: boolean; onOpenChange: (open: boolean) => void; modifiedCount?: number; onReset?: () => void; /** While true the fields stay in place but can't be edited. */ fetching?: boolean; className?: string; children: React.ReactNode;}) { const closeOnEscape = React.useEffectEvent(() => onOpenChange(false)); React.useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.shiftKey && !isOverlayOpen()) closeOnEscape(); }; window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); }, [open]); if (!open) return null; return ( <aside data-slot="playground-parameters" aria-label={title} className={cn("flex w-(--ds-layout-parameters) flex-col overflow-hidden rounded-card bg-surface shadow-card", className)}> <div className="mt-2 flex h-(--ds-layout-parameters-header) shrink-0 items-center gap-1.5 pr-2.5 pl-7"> <h2 className="text-body font-normal tracking-normal text-fg">{title}</h2> {modifiedCount > 0 && ( <Tooltip> <TooltipTrigger asChild> <span> <ParametersChangedCount count={modifiedCount} /> </span> </TooltipTrigger> <TooltipContent side="top">{modifiedCount} changed</TooltipContent> </Tooltip> )} <span className="ml-auto flex items-center gap-1"> {modifiedCount > 0 && onReset && ( <button type="button" onClick={onReset} disabled={fetching} className="flex h-6 items-center gap-1.5 rounded-tag px-2.5 text-caption text-fg-subtle outline-none transition-colors duration-150 hover:bg-wash-hover hover:text-fg focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-50" > <RotateCcw className="size-3.5" strokeWidth={1.5} /> Reset </button> )} <button type="button" onClick={() => onOpenChange(false)} aria-label={`Close ${title.toLowerCase()}`} className="grid size-7 place-items-center rounded-tag text-fg-subtle outline-none transition-colors duration-150 hover:bg-wash-control hover:text-fg focus-visible:ring-2 focus-visible:ring-focus-ring" > <X className="size-3.5" strokeWidth={1.5} /> </button> </span> </div> <ParametersScroller fetching={fetching}>{children}</ParametersScroller> </aside> );}/** A section: a caption header (a step darker than the descriptions), then its rows together in one gray box. */function ParameterGroup({ title, count, className, children }: { title: React.ReactNode; count?: React.ReactNode; className?: string; children: React.ReactNode }) { return ( <section data-slot="parameter-group" className="flex w-full min-w-0 flex-col"> <h3 className="mb-2 flex flex-wrap items-baseline gap-2 px-3 text-caption font-medium tracking-normal text-fg-muted"> {title} {count !== undefined && <span className="font-normal text-fg-subtle tabular-nums">{count}</span>} </h3> <div className={cn("divide-y divide-line overflow-hidden rounded-card bg-neutral-5 [&>*]:px-3", className)}>{children}</div> </section> );}/** One parameter. `stacked` puts the control under the description at full width (free text); `onReset` adds a reset icon. */function ParameterRow({ label, description, help, stacked = false, onReset, labelAction, children,}: { label: string; description?: React.ReactNode; /** A longer note behind an info icon. */ help?: React.ReactNode; stacked?: boolean; onReset?: () => void; labelAction?: React.ReactNode; children: React.ReactNode;}) { const labelId = React.useId(); const descriptionId = React.useId(); return ( <div role="group" aria-labelledby={labelId} aria-describedby={description ? descriptionId : undefined} className="min-w-0 py-3"> <div className="flex min-w-0 items-center justify-between gap-4"> <span className="inline-flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-1 text-body-sm text-fg"> <span id={labelId} className="min-w-0 [overflow-wrap:anywhere]"> {label} </span> {help && ( <Popover> <PopoverTrigger asChild> <button type="button" aria-label={`About ${label}`} className={ROW_ICON_BUTTON}> <Info className="size-3" /> </button> </PopoverTrigger> <PopoverContent align="start" collisionPadding={12} className="w-auto max-w-[calc(100vw-1.5rem)] [overflow-wrap:anywhere]"> {help} </PopoverContent> </Popover> )} {labelAction} {onReset && ( <button type="button" onClick={onReset} aria-label={`Reset ${label} to default`} title="Reset to default" className={ROW_ICON_BUTTON}> <RotateCcw className="size-3" /> </button> )} </span> {!stacked && <div className={cn(CONTROL, "flex max-w-[55%] shrink-0 justify-end")}>{children}</div>} </div> {description && ( <p id={descriptionId} className="mt-0.5 text-body-sm leading-field text-fg-muted [overflow-wrap:anywhere]"> {description} </p> )} {stacked && <div className={cn(CONTROL, "mt-2 w-full [&>input]:h-9")}>{children}</div>} </div> );}/** The panel's filled gray field: no border, a darker gray on hover. */function ParameterInput({ className, type = "text", ...props }: React.ComponentProps<"input">) { return ( <Input type={type} className={cn( FIELD, "w-28 max-w-full min-w-0 py-0 placeholder:text-fg-subtle", type === "number" && "tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", className, )} {...props} /> );}/** A number with its unit after it (ms, pages) inside the same gray field. */function ParameterUnitInput({ unit, className, ...props }: React.ComponentProps<"input"> & { unit: string }) { return ( <div className={cn("flex h-8 w-28 max-w-full min-w-0 items-center rounded-control bg-wash-control px-3 transition-colors duration-150 hover:bg-wash-selected", className)}> <ParameterInput type="number" className="h-auto min-w-0 flex-1 rounded-none bg-transparent p-0 hover:bg-transparent focus-visible:ring-0" {...props} /> <span className="ml-1 shrink-0 text-caption text-fg-subtle lowercase">{unit}</span> </div> );}/** The panel's filled gray select. */function ParameterSelect<T extends string>({ value, onValueChange, options, label, className }: { value: T; onValueChange: (value: T) => void; options: readonly { value: T; label: string }[]; label: string; className?: string }) { return ( <Select value={value} onValueChange={(next) => onValueChange(next as T)}> <SelectTrigger aria-label={label} className={cn(FIELD, "w-auto max-w-48 min-w-28 justify-between py-1.5 pr-2.5 pl-3 [&>span]:min-w-0 [&>span]:truncate", className)}> <SelectValue /> </SelectTrigger> <SelectContent align="end" className="w-auto"> {options.map((o) => ( <SelectItem key={o.value} value={o.value}> {o.label} </SelectItem> ))} </SelectContent> </Select> );}export { ParameterGroup, ParameterInput, ParameterRow, ParametersButton, ParametersChangedCount, ParameterSelect, ParameterUnitInput, PlaygroundParameters };Reference: context-dashboard c0ddf7a src/components/playground/parameters-panel.tsx.