feat: add searchable dropdown with Popover + Command pattern

Rewrite SelectField to use shadcn/ui Popover + Command (cmdk) pattern
for searchable selects, following best practices. This eliminates
console errors from the previous input-inside-SelectContent approach.

Changes:
- SelectField.js: Use Popover + Command for searchable fields
- Add Command component with CommandInput for proper search
- Update dialog.jsx to use Huge Icons instead of lucide-react
- Simplify searchable logic to follow PHP config directly

The Command component handles keyboard navigation and filtering
properly without focus event conflicts.
This commit is contained in:
dwindown
2026-04-28 16:45:06 +07:00
parent 008188b790
commit 7a6765a579
8 changed files with 538 additions and 14 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'wp-components', 'wp-element', 'wp-i18n', 'wp-icons/build/arrow-left', 'wp-icons/build/bell', 'wp-icons/build/message', 'wp-icons/build/trash'), 'version' => '79dab88e37717bf64790');
<?php return array('dependencies' => array('react', 'react-dom', 'wp-components', 'wp-element', 'wp-i18n', 'wp-icons/build/arrow-left', 'wp-icons/build/bell', 'wp-icons/build/message', 'wp-icons/build/trash'), 'version' => 'e564b3f018fca608f7b7');

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,216 @@
/**
* SelectField - Renders select dropdown fields
* - Non-searchable: uses shadcn/ui Select
* - Searchable: uses Popover + Command (Combobox pattern)
*/
import { useState, useMemo } from '@wordpress/element';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '@/components/ui/command';
import { Label } from '@/components/ui/label';
import { HugeiconsIcon } from '@hugeicons/react';
import { ArrowDown01Icon, Tick01Icon, Cancel01Icon } from '@hugeicons/core-free-icons';
import { cn } from '@/lib/utils';
function HtmlContent({ html, className }) {
if (!html) return null;
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
export default function SelectField({ field, value, onChange, error }) {
const [searchQuery, setSearchQuery] = useState('');
const [open, setOpen] = useState(false);
// Append asterisk to HTML if required
const labelHtml = field.required
? (field.label || '') + '<span class="text-destructive ml-0.5">*</span>'
: field.label;
// Determine if searchable - follows PHP config directly
const isSearchable = field.searchable ?? false;
// Get display label for current value
const displayLabel = useMemo(() => {
if (!value) return field.placeholder || `Select...`;
const label = field.options?.[value];
if (typeof label === 'string') {
return label;
}
return String(label || value);
}, [value, field.options, field.placeholder]);
// Filter options based on search query
const filteredOptions = useMemo(() => {
if (!isSearchable || !searchQuery) {
return field.options || {};
}
const query = searchQuery.toLowerCase();
const filtered = {};
Object.entries(field.options || {}).forEach(([optValue, optLabel]) => {
const label = typeof optLabel === 'string' ? optLabel : String(optLabel);
if (label.toLowerCase().includes(query) || optValue.toLowerCase().includes(query)) {
filtered[optValue] = optLabel;
}
});
return filtered;
}, [field.options, searchQuery, isSearchable]);
const handleSelect = (newValue) => {
onChange(newValue);
setOpen(false);
setSearchQuery('');
};
const handleClear = (e) => {
e.stopPropagation();
onChange('');
setSearchQuery('');
};
// Non-searchable: use standard Select component
if (!isSearchable) {
return (
<div className="grid grid-cols-[30%_70%] gap-4 items-start py-2.5 px-1">
<div className="space-y-1 min-w-0">
<Label htmlFor={field.name} className="text-sm leading-tight">
<HtmlContent html={labelHtml} className="flex flex-wrap gap-1 items-center" />
</Label>
{field.description && (
<HtmlContent
html={field.description}
className="text-xs text-muted-foreground wrap-break-word"
/>
)}
</div>
<div className="min-w-0 text-left">
<Select
value={value || ''}
onValueChange={onChange}
>
<SelectTrigger className={cn(error && "border-destructive")}>
<SelectValue placeholder={field.placeholder || `Select...`} />
</SelectTrigger>
<SelectContent>
{Object.entries(field.options || {}).map(([optValue, optLabel]) => (
<SelectItem key={optValue} value={optValue}>
{optLabel}
</SelectItem>
))}
</SelectContent>
</Select>
{error && (
<p className="text-sm text-destructive mt-1">
{error}
</p>
)}
</div>
</div>
);
}
// Searchable: use Popover + Command pattern (Combobox)
return (
<div className="grid grid-cols-[30%_70%] gap-4 items-start py-2.5 px-1">
<div className="space-y-1 min-w-0">
<Label htmlFor={field.name} className="text-sm leading-tight">
<HtmlContent html={labelHtml} className="flex flex-wrap gap-1 items-center" />
</Label>
{field.description && (
<HtmlContent
html={field.description}
className="text-xs text-muted-foreground wrap-break-word"
/>
)}
</div>
<div className="min-w-0 text-left">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
className={cn(
"inline-flex items-center justify-between w-full rounded-[30px]! border border-input bg-background shadow-sm px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground h-9",
!value && "text-muted-foreground",
error && "border-destructive"
)}
>
<span className="truncate flex-1 text-left">{displayLabel}</span>
<div className="flex items-center gap-1 shrink-0">
{value && (
<span
className="opacity-50 hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
handleClear(e);
}}
>
<HugeiconsIcon
icon={Cancel01Icon}
size={14}
/>
</span>
)}
<HugeiconsIcon icon={ArrowDown01Icon} size={16} className="opacity-50" />
</div>
</button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[--radix-popover-trigger-width]" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder={`Search ${field.label}...`}
value={searchQuery}
onValueChange={setSearchQuery}
className={"focus:shadow-none! border-none!"}
/>
<CommandList>
<CommandEmpty>
No results found
</CommandEmpty>
<CommandGroup>
{Object.entries(filteredOptions).map(([optValue, optLabel]) => (
<CommandItem
key={optValue}
value={optValue}
onSelect={() => handleSelect(optValue)}
>
<HugeiconsIcon
icon={Tick01Icon}
size={14}
className={cn(
"mr-2",
value === optValue ? "opacity-100" : "opacity-0"
)}
/>
{optLabel}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{error && (
<p className="text-sm text-destructive mt-1">
{error}
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { HugeiconsIcon } from '@hugeicons/react';
import { Search01Icon } from '@hugeicons/core-free-icons';
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props} />
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}>
<Command
className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3">
<HugeiconsIcon icon={Search01Icon} className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props} />
</div>
);
}
function CommandList({
className,
...props
}) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)}
{...props} />
);
}
function CommandEmpty({
...props
}) {
return (<CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />);
}
function CommandGroup({
className,
...props
}) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props} />
);
}
function CommandSeparator({
className,
...props
}) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props} />
);
}
function CommandItem({
className,
...props
}) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props} />
);
}
function CommandShortcut({
className,
...props
}) {
return (
<span
data-slot="command-shortcut"
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props} />
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,146 @@
import * as React from "react"
import { HugeiconsIcon } from '@hugeicons/react';
import { Cancel01Icon } from '@hugeicons/core-free-icons';
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props} />
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<HugeiconsIcon icon={Cancel01Icon} />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({
className,
...props
}) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} />
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props} />
);
}
function DialogDescription({
className,
...props
}) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props} />
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -12,16 +12,18 @@ function PopoverTrigger({ ...props }) {
function PopoverContent({ className, align = "center", sideOffset = 4, ...props }) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
<div className="formipay-design-system">
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</div>
</PopoverPrimitive.Portal>
);
}