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:
File diff suppressed because one or more lines are too long
@@ -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
216
src/admin/components/field-renderer/FieldTypes/SelectField.js
Normal file
216
src/admin/components/field-renderer/FieldTypes/SelectField.js
Normal 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>
|
||||
);
|
||||
}
|
||||
160
src/admin/components/ui/command.jsx
Normal file
160
src/admin/components/ui/command.jsx
Normal 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,
|
||||
}
|
||||
146
src/admin/components/ui/dialog.jsx
Normal file
146
src/admin/components/ui/dialog.jsx
Normal 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,
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user