Filter the icon grid, and ask before removing a panel
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UytviPMJbXzD8P84nLvXcq
This commit is contained in:
@@ -27,6 +27,7 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import useAuth from "@/hooks/useAuth"
|
import useAuth from "@/hooks/useAuth"
|
||||||
import useCustomToast from "@/hooks/useCustomToast"
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
import { handleError } from "@/utils"
|
import { handleError } from "@/utils"
|
||||||
|
|
||||||
/** As many characters as a device puts on the wall. */
|
/** As many characters as a device puts on the wall. */
|
||||||
@@ -207,6 +208,9 @@ function PanelRow({
|
|||||||
// ponytail: a two-step button rather than a dialog, since this one already
|
// ponytail: a two-step button rather than a dialog, since this one already
|
||||||
// lives inside a dialog.
|
// lives inside a dialog.
|
||||||
const [confirmUnpair, setConfirmUnpair] = useState(false)
|
const [confirmUnpair, setConfirmUnpair] = useState(false)
|
||||||
|
// Removing the panel throws away more than unpairing does, so it asks the
|
||||||
|
// same way — the icon turns destructive and only the second press fires.
|
||||||
|
const [confirmRemove, setConfirmRemove] = useState(false)
|
||||||
const unpair = useUnpairPanel(panel.id)
|
const unpair = useUnpairPanel(panel.id)
|
||||||
const assigned = panel.dashboards ?? []
|
const assigned = panel.dashboards ?? []
|
||||||
const typed = code.trim().toUpperCase()
|
const typed = code.trim().toUpperCase()
|
||||||
@@ -247,6 +251,11 @@ function PanelRow({
|
|||||||
|
|
||||||
const link = host ? `${host}/panel/${panel.id}` : ""
|
const link = host ? `${host}/panel/${panel.id}` : ""
|
||||||
const remoteLink = remoteHost ? `${remoteHost}/panel` : ""
|
const remoteLink = remoteHost ? `${remoteHost}/panel` : ""
|
||||||
|
// The trash icon carries no text of its own, so the confirm step says what
|
||||||
|
// the next press does through the label a reader or a hover gets.
|
||||||
|
const removeLabel = confirmRemove
|
||||||
|
? `Confirm — remove ${panel.id}`
|
||||||
|
: `Remove ${panel.id}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3" data-testid={`panel-${panel.id}`}>
|
<div className="grid gap-3" data-testid={`panel-${panel.id}`}>
|
||||||
@@ -264,13 +273,25 @@ function PanelRow({
|
|||||||
{panel.id}
|
{panel.id}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant={confirmRemove ? "destructive" : "ghost"}
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-11 shrink-0 text-muted-foreground md:size-8"
|
className={cn(
|
||||||
aria-label={`Remove ${panel.id}`}
|
"size-11 shrink-0 md:size-8",
|
||||||
|
!confirmRemove && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
aria-label={removeLabel}
|
||||||
|
title={removeLabel}
|
||||||
data-testid={`remove-panel-${panel.id}`}
|
data-testid={`remove-panel-${panel.id}`}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
onClick={onRemove}
|
onBlur={() => setConfirmRemove(false)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!confirmRemove) {
|
||||||
|
setConfirmRemove(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setConfirmRemove(false)
|
||||||
|
onRemove()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 />
|
<Trash2 />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -307,8 +307,8 @@ const CHART_SOURCES = [
|
|||||||
* Radix forbids an empty `SelectItem` value — which is why the selects this
|
* Radix forbids an empty `SelectItem` value — which is why the selects this
|
||||||
* replaces could set an icon but never take one back.
|
* replaces could set an icon but never take one back.
|
||||||
*
|
*
|
||||||
* ponytail: no filter field. `ICONS` is a few dozen and the grid shows all of
|
* The field at the top narrows the grid by name, so the glyphs stay findable
|
||||||
* it without scrolling; add one when the map outgrows a popover.
|
* as the map grows past what a popover can show at once.
|
||||||
*/
|
*/
|
||||||
function IconPicker({
|
function IconPicker({
|
||||||
value,
|
value,
|
||||||
@@ -328,10 +328,21 @@ function IconPicker({
|
|||||||
onChange: (icon: string) => void
|
onChange: (icon: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
// Cleared whenever the popover opens or closes: a filter left over from last
|
||||||
|
// time hides icons that are still there.
|
||||||
|
const [filter, setFilter] = useState("")
|
||||||
const Current = ICONS[value]
|
const Current = ICONS[value]
|
||||||
|
const needle = filter.trim().toLowerCase()
|
||||||
|
const shown = ICON_NAMES.filter((name) => name.toLowerCase().includes(needle))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next)
|
||||||
|
setFilter("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -351,9 +362,18 @@ function IconPicker({
|
|||||||
<ChevronDown className="ml-auto opacity-50" />
|
<ChevronDown className="ml-auto opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="start" className="w-auto p-2">
|
{/* Fixed width rather than auto: the grid would otherwise shrink under
|
||||||
<div className="grid grid-cols-6 gap-1">
|
the cursor as the filter narrows it. */}
|
||||||
{ICON_NAMES.map((name) => {
|
<PopoverContent align="start" className="w-60 p-2">
|
||||||
|
<Input
|
||||||
|
value={filter}
|
||||||
|
placeholder="Filter"
|
||||||
|
aria-label="Filter icons"
|
||||||
|
className="mb-2 h-8 text-sm"
|
||||||
|
onChange={(event) => setFilter(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-6 justify-items-center gap-1">
|
||||||
|
{shown.map((name) => {
|
||||||
const Glyph = ICONS[name]
|
const Glyph = ICONS[name]
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -376,6 +396,11 @@ function IconPicker({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<p className="px-1 text-sm text-muted-foreground">
|
||||||
|
No icon by that name.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
Reference in New Issue
Block a user