Post Object
A dropdown component for selecting one or more posts, pages, or custom content items with search and filtering capabilities.
Featured Post
Select a post to feature on the homepage
"use client"
import { useState } from "react"
import { PostObject, type PostObjectItem } from "@/components/ui/post-object"
const posts = [
{
id: 1,
title: "Getting Started with React",
type: "post",
status: "publish" as const,
excerpt: "Learn the fundamentals of React development with this comprehensive guide.",
date: "2024-01-15",
},
{
id: 2,
title: "Advanced TypeScript Patterns",
type: "post",
status: "publish" as const,
excerpt: "Explore advanced TypeScript patterns for better type safety.",
date: "2024-01-10",
},
{
id: 3,
title: "Building UIs with Tailwind CSS",
type: "post",
status: "draft" as const,
excerpt: "Create beautiful, responsive interfaces with Tailwind CSS.",
date: "2024-01-05",
},
{
id: 4,
title: "State Management Guide",
type: "guide",
status: "publish" as const,
excerpt: "Complete guide to managing state in modern React applications.",
date: "2024-01-20",
},
{
id: 5,
title: "API Design Best Practices",
type: "guide",
status: "publish" as const,
excerpt: "Design robust APIs that scale with your application needs.",
date: "2024-01-12",
},
] satisfies PostObjectItem[]
export function PostObjectDemo() {
const [selectedPost, setSelectedPost] = useState<PostObjectItem | null>(null)
return (
<div className="w-full max-w-2xl space-y-4">
<div>
<h3 className="text-lg font-semibold">Featured Post</h3>
<p className="text-sm text-muted-foreground">
Select a post to feature on the homepage
</p>
</div>
<PostObject
items={posts}
selectedItems={selectedPost ? [selectedPost] : []}
onChange={(items) => {
const postItems = items as PostObjectItem[]
setSelectedPost(postItems.length > 0 ? postItems[0] || null : null)
}}
multiple={false}
allowNull={true}
postTypes={[]}
postStatuses={["publish", "draft"]}
showFilters={["search", "post_type"]}
placeholder="Select a featured post..."
searchPlaceholder="Search posts..."
showExcerpt={true}
showDate={true}
/>
{selectedPost && (
<div className="mt-6 p-4 bg-muted/30 rounded-lg">
<h4 className="font-medium">Selected Post:</h4>
<div className="mt-2">
<div className="font-medium">{selectedPost.title}</div>
<div className="text-sm text-muted-foreground mt-1">
{selectedPost.excerpt}
</div>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<span className="capitalize">{selectedPost.type}</span>
<span>•</span>
<span>{selectedPost.date}</span>
<span>•</span>
<span className="capitalize">{selectedPost.status}</span>
</div>
</div>
</div>
)}
</div>
)
}
Installation
CLI
npx shadcn@latest add "https://acfui.com/r/post-object"
pnpm dlx shadcn@latest add "https://acfui.com/r/post-object"
yarn dlx shadcn@latest add "https://acfui.com/r/post-object"
bun x shadcn@latest add "https://acfui.com/r/post-object"
Manual
Install the following dependencies:
npm install lucide-react class-variance-authority
pnpm add lucide-react class-variance-authority
yarn add lucide-react class-variance-authority
bun add lucide-react class-variance-authority
Copy and paste the following code into your project.
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import {
Check,
ChevronDown,
Eye,
GripVertical,
X,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const postObjectVariants = cva(
"relative w-full rounded-md border border-input bg-background text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
variant: {
default: "border-input",
error: "border-destructive focus:ring-destructive",
},
size: {
sm: "h-9 px-3 py-2 text-xs",
md: "h-10 px-3 py-2 text-sm",
lg: "h-11 px-4 py-2 text-base",
},
},
defaultVariants: {
variant: "default",
size: "md",
},
}
)
export interface PostObjectItem {
id: string | number
title: string
type?: string
status?: "publish" | "draft" | "private"
taxonomy?: string[]
featured_image?: string
excerpt?: string
date?: string
url?: string
}
export interface PostObjectFilter {
search?: string
post_type?: string[]
post_status?: string[]
taxonomy?: string[]
}
export interface PostObjectProps
extends Omit<
React.HTMLAttributes<HTMLDivElement>,
"onChange" | "onLoad" | "onSelect" | "onRemove"
>,
VariantProps<typeof postObjectVariants> {
// Data props
items?: PostObjectItem[]
selectedItems?: PostObjectItem[]
// Configuration
multiple?: boolean
allowNull?: boolean
required?: boolean
// Async data loading
onLoad?: (filter: PostObjectFilter) => Promise<PostObjectItem[]>
loading?: boolean
// Filtering options
postTypes?: string[]
postStatuses?: string[]
taxonomies?: string[]
showFilters?: boolean | string[]
// Display options
showPreview?: boolean
showFeaturedImage?: boolean
showExcerpt?: boolean
showDate?: boolean
sortable?: boolean
// Return format
returnFormat?: "object" | "id"
// Event handlers
onChange?: (items: PostObjectItem[] | string[] | number[] | null) => void
onSelect?: (item: PostObjectItem) => void
onRemove?: (item: PostObjectItem) => void
// Labels and messages
placeholder?: string
searchPlaceholder?: string
noItemsText?: string
loadingText?: string
instructions?: string
// Validation
error?: string
}
const PostObject = React.forwardRef<HTMLDivElement, PostObjectProps>(
(
{
className,
variant,
size,
items = [],
selectedItems = [],
multiple = false,
allowNull = true,
required = false,
onLoad,
loading = false,
postTypes = [],
postStatuses = [],
taxonomies = [],
showFilters = false,
showPreview = false,
showFeaturedImage = false,
showExcerpt = false,
showDate = false,
sortable = false,
returnFormat = "object",
onChange,
onSelect,
onRemove,
placeholder = "Select post...",
searchPlaceholder = "Search posts...",
noItemsText = "No posts found",
loadingText = "Loading...",
instructions,
error,
...props
},
ref
) => {
const [open, setOpen] = React.useState(false)
const [searchQuery, setSearchQuery] = React.useState("")
const [selectedPostType, setSelectedPostType] = React.useState<string>("all")
const [selectedPostStatus, setSelectedPostStatus] = React.useState<string>("all")
const [selectedTaxonomy, setSelectedTaxonomy] = React.useState<string>("all")
const [availableItems, setAvailableItems] = React.useState<PostObjectItem[]>(items)
const [internalSelected, setInternalSelected] = React.useState<PostObjectItem[]>(selectedItems || [])
const [isLoading, setIsLoading] = React.useState(loading)
// Debounced search
React.useEffect(() => {
const timeoutId = setTimeout(() => {
if (onLoad) {
setIsLoading(true)
const filter: PostObjectFilter = {
search: searchQuery || undefined,
post_type: selectedPostType === "all" ? postTypes : [selectedPostType],
post_status: selectedPostStatus === "all" ? postStatuses : [selectedPostStatus],
taxonomy: selectedTaxonomy === "all" ? undefined : [selectedTaxonomy],
}
onLoad(filter)
.then((loadedItems) => {
setAvailableItems(loadedItems || [])
setIsLoading(false)
})
.catch(() => {
setAvailableItems([])
setIsLoading(false)
})
}
}, 300)
return () => clearTimeout(timeoutId)
}, [searchQuery, selectedPostType, selectedPostStatus, selectedTaxonomy, onLoad, postTypes, postStatuses])
// Update internal state when selectedItems prop changes
React.useEffect(() => {
setInternalSelected(selectedItems || [])
}, [selectedItems])
// Update available items when items prop changes
React.useEffect(() => {
if (!onLoad) {
setAvailableItems(items || [])
}
}, [items, onLoad])
const handleSelectItem = React.useCallback(
(item: PostObjectItem) => {
let newSelected: PostObjectItem[]
if (multiple) {
if (internalSelected.some((selected) => selected.id === item.id)) {
// Remove if already selected
newSelected = internalSelected.filter((selected) => selected.id !== item.id)
} else {
// Add to selection
newSelected = [...internalSelected, item]
}
} else {
// Single selection - replace current selection
newSelected = [item]
setOpen(false)
}
setInternalSelected(newSelected)
onSelect?.(item)
if (onChange) {
const result = returnFormat === "object"
? newSelected
: newSelected.map((item) => item.id) as string[] | number[]
onChange(result)
}
},
[internalSelected, multiple, returnFormat, onChange, onSelect]
)
const handleRemoveItem = React.useCallback(
(item: PostObjectItem) => {
const newSelected = internalSelected.filter(
(selected) => selected.id !== item.id
)
setInternalSelected(newSelected)
onRemove?.(item)
if (onChange) {
const result = returnFormat === "object"
? newSelected
: newSelected.map((item) => item.id) as string[] | number[]
onChange(result)
}
},
[internalSelected, returnFormat, onChange, onRemove]
)
const handleClearAll = React.useCallback(() => {
setInternalSelected([])
if (onChange) {
onChange(returnFormat === "object" ? [] : [])
}
}, [onChange, returnFormat])
const getFilteredItems = React.useCallback(() => {
if (onLoad) return availableItems.filter(item => item && item.id !== undefined)
return availableItems.filter((item) => {
if (!item || item.id === undefined) return false
const matchesSearch = !searchQuery ||
item.title?.toLowerCase().includes(searchQuery.toLowerCase())
const matchesPostType = selectedPostType === "all" ||
!selectedPostType || item.type === selectedPostType
const matchesPostStatus = selectedPostStatus === "all" ||
!selectedPostStatus || item.status === selectedPostStatus
const matchesTaxonomy = selectedTaxonomy === "all" ||
!selectedTaxonomy || item.taxonomy?.includes(selectedTaxonomy)
return matchesSearch && matchesPostType && matchesPostStatus && matchesTaxonomy
})
}, [availableItems, searchQuery, selectedPostType, selectedPostStatus, selectedTaxonomy, onLoad])
const filteredItems = getFilteredItems()
const isFiltersVisible = showFilters === true || (Array.isArray(showFilters) && showFilters.length > 0)
const shouldShowSearch = showFilters === true || (Array.isArray(showFilters) && showFilters.includes("search"))
const shouldShowPostType = showFilters === true || (Array.isArray(showFilters) && showFilters.includes("post_type"))
const shouldShowPostStatus = showFilters === true || (Array.isArray(showFilters) && showFilters.includes("post_status"))
const shouldShowTaxonomy = showFilters === true || (Array.isArray(showFilters) && showFilters.includes("taxonomy"))
const getDisplayText = () => {
if (internalSelected.length === 0) return placeholder
if (internalSelected.length === 1) return internalSelected[0]?.title || placeholder
return `${internalSelected.length} posts selected`
}
return (
<div ref={ref} className={cn("space-y-2", className)} {...props}>
{/* Instructions */}
{instructions && (
<p className="text-sm text-muted-foreground">{instructions}</p>
)}
{/* Filters */}
{isFiltersVisible && postTypes.length > 0 && (
<div className="space-y-3 p-3 border rounded-md bg-muted/30">
<div className="flex flex-col gap-3 sm:flex-row">
{shouldShowPostType && postTypes.length > 0 && (
<Select value={selectedPostType} onValueChange={setSelectedPostType}>
<SelectTrigger className="w-full sm:w-[140px]">
<SelectValue placeholder="Post type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{postTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{shouldShowPostStatus && postStatuses.length > 0 && (
<Select value={selectedPostStatus} onValueChange={setSelectedPostStatus}>
<SelectTrigger className="w-full sm:w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{postStatuses.map((status) => (
<SelectItem key={status} value={status}>
{status}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{shouldShowTaxonomy && taxonomies.length > 0 && (
<Select value={selectedTaxonomy} onValueChange={setSelectedTaxonomy}>
<SelectTrigger className="w-full sm:w-[140px]">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All categories</SelectItem>
{taxonomies.map((taxonomy) => (
<SelectItem key={taxonomy} value={taxonomy}>
{taxonomy}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
)}
{/* Main Selection Interface */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
postObjectVariants({ variant, size }),
"justify-between font-normal",
internalSelected.length === 0 && "text-muted-foreground"
)}
>
<span className="truncate">{getDisplayText()}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
{shouldShowSearch && (
<CommandInput
placeholder={searchPlaceholder}
value={searchQuery}
onValueChange={setSearchQuery}
/>
)}
<CommandList>
<CommandEmpty>
{isLoading ? loadingText : noItemsText}
</CommandEmpty>
{allowNull && internalSelected.length > 0 && (
<CommandGroup>
<CommandItem onSelect={handleClearAll} className="text-muted-foreground">
<X className="mr-2 h-4 w-4" />
Clear selection{multiple && "s"}
</CommandItem>
</CommandGroup>
)}
<CommandGroup>
{filteredItems
.filter((item): item is PostObjectItem => item != null && item.id !== undefined)
.map((item) => {
const isSelected = internalSelected.some((selected) => selected.id === item.id)
return (
<CommandItem
key={item.id}
onSelect={() => handleSelectItem(item)}
className="flex items-center gap-3 p-3"
>
<div className="flex h-4 w-4 items-center justify-center">
{isSelected && <Check className="h-4 w-4" />}
</div>
{showFeaturedImage && item.featured_image && (
<img
src={item.featured_image}
alt={item.title}
className="h-8 w-8 rounded object-cover"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{item.title}</span>
{item.status && item.status !== "publish" && (
<Badge variant="secondary" className="text-xs">
{item.status}
</Badge>
)}
{item.type && (
<Badge variant="outline" className="text-xs">
{item.type}
</Badge>
)}
</div>
{showExcerpt && item.excerpt && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{item.excerpt}
</p>
)}
{showDate && item.date && (
<span className="text-xs text-muted-foreground">
{item.date}
</span>
)}
</div>
{showPreview && item.url && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
asChild
onClick={(e) => e.stopPropagation()}
>
<a href={item.url} target="_blank" rel="noopener noreferrer">
<Eye className="h-4 w-4" />
</a>
</Button>
)}
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Selected Items (for multiple selection) */}
{multiple && internalSelected.length > 0 && (
<div className="space-y-2 p-3 border rounded-md bg-muted/30">
<h4 className="text-sm font-medium">Selected Posts</h4>
<div className="space-y-1">
{internalSelected.map((item, index) => (
<div
key={item.id}
className="flex items-center gap-2 p-2 bg-background rounded border"
>
{sortable && (
<GripVertical className="h-4 w-4 text-muted-foreground cursor-move" />
)}
{showFeaturedImage && item.featured_image && (
<img
src={item.featured_image}
alt={item.title}
className="h-6 w-6 rounded object-cover"
/>
)}
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate">{item.title}</span>
{item.type && (
<Badge variant="outline" className="ml-2 text-xs">
{item.type}
</Badge>
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => handleRemoveItem(item)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
{/* Error Message */}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
)
}
)
PostObject.displayName = "PostObject"
export { PostObject, postObjectVariants }
Update the import paths to match your project setup.
Usage
import { PostObject } from "@/components/ui/post-object"
<PostObject
items={posts}
selectedItems={selectedPosts}
onChange={(items) => setSelectedPosts(items)}
placeholder="Select posts..."
allowNull
/>
Examples
Basic
A basic post object field with static data and search functionality.
Featured Post
Select a post to feature on the homepage
"use client"
import { useState } from "react"
import { PostObject, type PostObjectItem } from "@/components/ui/post-object"
const posts = [
{
id: 1,
title: "Getting Started with React",
type: "post",
status: "publish" as const,
excerpt: "Learn the fundamentals of React development with this comprehensive guide.",
date: "2024-01-15",
},
{
id: 2,
title: "Advanced TypeScript Patterns",
type: "post",
status: "publish" as const,
excerpt: "Explore advanced TypeScript patterns for better type safety.",
date: "2024-01-10",
},
{
id: 3,
title: "Building UIs with Tailwind CSS",
type: "post",
status: "draft" as const,
excerpt: "Create beautiful, responsive interfaces with Tailwind CSS.",
date: "2024-01-05",
},
{
id: 4,
title: "State Management Guide",
type: "guide",
status: "publish" as const,
excerpt: "Complete guide to managing state in modern React applications.",
date: "2024-01-20",
},
{
id: 5,
title: "API Design Best Practices",
type: "guide",
status: "publish" as const,
excerpt: "Design robust APIs that scale with your application needs.",
date: "2024-01-12",
},
] satisfies PostObjectItem[]
export function PostObjectDemo() {
const [selectedPost, setSelectedPost] = useState<PostObjectItem | null>(null)
return (
<div className="w-full max-w-2xl space-y-4">
<div>
<h3 className="text-lg font-semibold">Featured Post</h3>
<p className="text-sm text-muted-foreground">
Select a post to feature on the homepage
</p>
</div>
<PostObject
items={posts}
selectedItems={selectedPost ? [selectedPost] : []}
onChange={(items) => {
const postItems = items as PostObjectItem[]
setSelectedPost(postItems.length > 0 ? postItems[0] || null : null)
}}
multiple={false}
allowNull={true}
postTypes={[]}
postStatuses={["publish", "draft"]}
showFilters={["search", "post_type"]}
placeholder="Select a featured post..."
searchPlaceholder="Search posts..."
showExcerpt={true}
showDate={true}
/>
{selectedPost && (
<div className="mt-6 p-4 bg-muted/30 rounded-lg">
<h4 className="font-medium">Selected Post:</h4>
<div className="mt-2">
<div className="font-medium">{selectedPost.title}</div>
<div className="text-sm text-muted-foreground mt-1">
{selectedPost.excerpt}
</div>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<span className="capitalize">{selectedPost.type}</span>
<span>•</span>
<span>{selectedPost.date}</span>
<span>•</span>
<span className="capitalize">{selectedPost.status}</span>
</div>
</div>
</div>
)}
</div>
)
}
Multiple Selection
Configure for multiple post selection with filters and sortable display.
Related Content
Select up to 5 related posts to display in the sidebar
Choose posts that are related to the current content. You can select up to 5 posts.
"use client"
import { useState } from "react"
import { PostObject, type PostObjectItem } from "@/components/ui/post-object"
const posts = [
{
id: 1,
title: "React Performance Optimization",
type: "post",
status: "publish" as const,
taxonomy: ["React", "Performance"],
excerpt: "Learn advanced techniques to optimize React applications for better performance.",
date: "2024-01-15",
featured_image: "https://picsum.photos/400/300?random=1",
},
{
id: 2,
title: "TypeScript Best Practices",
type: "post",
status: "publish" as const,
taxonomy: ["TypeScript", "Best Practices"],
excerpt: "Comprehensive guide to TypeScript best practices for enterprise applications.",
date: "2024-01-10",
featured_image: "https://picsum.photos/400/300?random=2",
},
{
id: 3,
title: "CSS Grid Mastery",
type: "tutorial",
status: "publish" as const,
taxonomy: ["CSS", "Grid"],
excerpt: "Master CSS Grid layout with practical examples and real-world use cases.",
date: "2024-01-05",
featured_image: "https://picsum.photos/400/300?random=3",
},
{
id: 4,
title: "Next.js 14 Features",
type: "guide",
status: "publish" as const,
taxonomy: ["Next.js", "React"],
excerpt: "Explore the latest features in Next.js 14 and how to use them in your projects.",
date: "2024-01-20",
featured_image: "https://picsum.photos/400/300?random=4",
},
{
id: 5,
title: "Database Optimization",
type: "guide",
status: "draft" as const,
taxonomy: ["Database", "Performance"],
excerpt: "Optimize database queries and improve application performance.",
date: "2024-01-12",
featured_image: "https://picsum.photos/400/300?random=5",
},
{
id: 6,
title: "GraphQL API Design",
type: "post",
status: "publish" as const,
taxonomy: ["GraphQL", "API"],
excerpt: "Design efficient GraphQL APIs with proper schema design and resolver patterns.",
date: "2024-01-25",
featured_image: "https://picsum.photos/400/300?random=6",
},
{
id: 7,
title: "Testing Strategies",
type: "guide",
status: "publish" as const,
taxonomy: ["Testing", "QA"],
excerpt: "Comprehensive testing strategies for modern web applications.",
date: "2024-01-18",
featured_image: "https://picsum.photos/400/300?random=7",
},
{
id: 8,
title: "DevOps Fundamentals",
type: "tutorial",
status: "private" as const,
taxonomy: ["DevOps", "Infrastructure"],
excerpt: "Learn DevOps fundamentals including CI/CD pipelines and infrastructure as code.",
date: "2024-01-03",
featured_image: "https://picsum.photos/400/300?random=8",
},
] satisfies PostObjectItem[]
export function PostObjectMultipleDemo() {
const [selectedPosts, setSelectedPosts] = useState<PostObjectItem[]>([])
return (
<div className="w-full max-w-4xl space-y-4">
<div>
<h3 className="text-lg font-semibold">Related Content</h3>
<p className="text-sm text-muted-foreground">
Select up to 5 related posts to display in the sidebar
</p>
</div>
<PostObject
items={posts}
selectedItems={selectedPosts}
onChange={(items) => setSelectedPosts(items as PostObjectItem[])}
multiple={true}
postTypes={["post", "guide", "tutorial"]}
postStatuses={["publish", "draft", "private"]}
taxonomies={["React", "TypeScript", "CSS", "Next.js", "Database", "GraphQL", "Testing", "DevOps"]}
showFilters={["search", "post_type", "post_status", "taxonomy"]}
placeholder="Select related posts..."
searchPlaceholder="Search by title or content..."
showExcerpt={true}
showDate={true}
showFeaturedImage={true}
instructions="Choose posts that are related to the current content. You can select up to 5 posts."
/>
{selectedPosts.length > 0 && (
<div className="mt-6 p-4 bg-muted/30 rounded-lg">
<h4 className="font-medium mb-3">Selected Posts ({selectedPosts.length}):</h4>
<div className="grid gap-3">
{selectedPosts.map((post) => (
<div key={post.id} className="flex items-center gap-3 p-3 bg-background rounded border">
{post.featured_image && (
<img
src={post.featured_image}
alt={post.title}
className="w-16 h-16 rounded object-cover flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{post.title}</div>
<div className="text-xs text-muted-foreground mt-1">
{post.excerpt}
</div>
<div className="flex items-center gap-2 mt-2">
<span className="text-xs bg-primary/10 text-primary px-2 py-1 rounded">
{post.type}
</span>
<span className="text-xs bg-secondary/10 text-secondary-foreground px-2 py-1 rounded">
{post.status}
</span>
<span className="text-xs text-muted-foreground">
{post.date}
</span>
</div>
{post.taxonomy && (
<div className="flex flex-wrap gap-1 mt-2">
{post.taxonomy.map((tag) => (
<span key={tag} className="text-xs bg-muted text-muted-foreground px-2 py-1 rounded">
{tag}
</span>
))}
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
Async Loading
Load posts dynamically with search and filtering. Perfect for large datasets.
Dynamic Content Loader
Search and filter from a large dataset with async loading
Start typing to search through our content database
"use client"
import { useState } from "react"
import { PostObject, type PostObjectItem, type PostObjectFilter } from "@/components/ui/post-object"
// Simulate a large dataset
const mockDatabase = [
{
id: 1,
title: "Introduction to React Hooks",
type: "post",
status: "publish" as const,
taxonomy: ["React", "JavaScript", "Frontend"],
excerpt: "Learn how to use React Hooks to manage state and side effects in functional components...",
date: "2024-01-15",
featured_image: "https://picsum.photos/400/300?random=1",
url: "/posts/react-hooks-intro",
},
{
id: 2,
title: "Advanced TypeScript Patterns",
type: "post",
status: "publish" as const,
taxonomy: ["TypeScript", "Advanced", "Development"],
excerpt: "Explore advanced TypeScript patterns including conditional types, mapped types, and template literals...",
date: "2024-01-10",
featured_image: "https://picsum.photos/400/300?random=2",
url: "/posts/advanced-typescript",
},
{
id: 3,
title: "Building Responsive UIs",
type: "tutorial",
status: "publish" as const,
taxonomy: ["CSS", "Responsive", "UI"],
excerpt: "Create beautiful, responsive user interfaces that work on all devices...",
date: "2024-01-05",
featured_image: "https://picsum.photos/400/300?random=3",
url: "/tutorials/responsive-ui",
},
{
id: 4,
title: "State Management with Zustand",
type: "guide",
status: "publish" as const,
taxonomy: ["State Management", "Zustand", "React"],
excerpt: "Learn how to manage application state efficiently with Zustand...",
date: "2024-01-20",
featured_image: "https://picsum.photos/400/300?random=4",
url: "/guides/zustand-state",
},
{
id: 5,
title: "API Design Best Practices",
type: "guide",
status: "draft" as const,
taxonomy: ["API", "Backend", "REST"],
excerpt: "Design robust and maintainable APIs that scale with your application...",
date: "2024-01-12",
featured_image: "https://picsum.photos/400/300?random=5",
url: "/guides/api-design",
},
{
id: 6,
title: "Component Architecture Patterns",
type: "tutorial",
status: "publish" as const,
taxonomy: ["Architecture", "Components", "React"],
excerpt: "Build scalable component architectures with these proven patterns...",
date: "2024-01-08",
featured_image: "https://picsum.photos/400/300?random=6",
url: "/tutorials/component-architecture",
},
{
id: 7,
title: "Testing React Components",
type: "post",
status: "publish" as const,
taxonomy: ["Testing", "React", "Jest"],
excerpt: "Write comprehensive tests for your React components using Jest and React Testing Library...",
date: "2024-01-25",
featured_image: "https://picsum.photos/400/300?random=7",
url: "/posts/testing-react",
},
{
id: 8,
title: "Performance Optimization",
type: "guide",
status: "publish" as const,
taxonomy: ["Performance", "Optimization", "React"],
excerpt: "Optimize your React applications for better performance and user experience...",
date: "2024-01-18",
featured_image: "https://picsum.photos/400/300?random=8",
url: "/guides/performance-optimization",
},
{
id: 9,
title: "Server-Side Rendering with Next.js",
type: "tutorial",
status: "draft" as const,
taxonomy: ["Next.js", "SSR", "React"],
excerpt: "Implement server-side rendering for better SEO and performance...",
date: "2024-01-03",
featured_image: "https://picsum.photos/400/300?random=9",
url: "/tutorials/nextjs-ssr",
},
{
id: 10,
title: "Database Design Fundamentals",
type: "guide",
status: "publish" as const,
taxonomy: ["Database", "SQL", "Backend"],
excerpt: "Learn the fundamentals of database design and normalization...",
date: "2024-01-14",
featured_image: "https://picsum.photos/400/300?random=10",
url: "/guides/database-design",
},
] satisfies PostObjectItem[]
// Simulate API call with delay
const fetchPosts = async (filter: PostObjectFilter): Promise<PostObjectItem[]> => {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 800))
let filteredPosts = mockDatabase
// Apply search filter
if (filter.search) {
const searchTerm = filter.search.toLowerCase()
filteredPosts = filteredPosts.filter(post =>
post.title.toLowerCase().includes(searchTerm) ||
post.excerpt?.toLowerCase().includes(searchTerm) ||
post.taxonomy?.some(tag => tag.toLowerCase().includes(searchTerm))
)
}
// Apply post type filter
if (filter.post_type && filter.post_type.length > 0) {
filteredPosts = filteredPosts.filter(post =>
filter.post_type!.includes(post.type || "")
)
}
// Apply post status filter
if (filter.post_status && filter.post_status.length > 0) {
filteredPosts = filteredPosts.filter(post =>
filter.post_status!.includes(post.status || "")
)
}
// Apply taxonomy filter
if (filter.taxonomy && filter.taxonomy.length > 0) {
filteredPosts = filteredPosts.filter(post =>
post.taxonomy?.some(tag => filter.taxonomy!.includes(tag))
)
}
return filteredPosts
}
export function PostObjectAsyncDemo() {
const [selectedPosts, setSelectedPosts] = useState<PostObjectItem[]>([])
return (
<div className="w-full max-w-4xl mx-auto space-y-4">
<div>
<h3 className="text-lg font-semibold mb-2">Dynamic Content Loader</h3>
<p className="text-sm text-muted-foreground mb-4">
Search and filter from a large dataset with async loading
</p>
</div>
<PostObject
onLoad={fetchPosts}
selectedItems={selectedPosts}
onChange={(items) => setSelectedPosts(items as PostObjectItem[])}
multiple={true}
postTypes={["post", "guide", "tutorial"]}
postStatuses={["publish", "draft"]}
taxonomies={["React", "TypeScript", "CSS", "API", "Testing", "Performance"]}
showFilters={["search", "post_type", "post_status", "taxonomy"]}
showFeaturedImage={true}
showExcerpt={true}
showDate={true}
showPreview={true}
instructions="Start typing to search through our content database"
placeholder="Search posts..."
searchPlaceholder="Search posts by title, content, or tags..."
loadingText="Searching content..."
/>
{selectedPosts.length > 0 && (
<div className="mt-8 p-4 bg-muted/30 rounded-lg">
<h4 className="font-medium mb-2">Selected Content ({selectedPosts.length}):</h4>
<div className="grid gap-2">
{selectedPosts.map((post) => (
<div key={post.id} className="flex items-center gap-3 p-2 bg-background rounded border">
{post.featured_image && (
<img
src={post.featured_image}
alt={post.title}
className="w-12 h-12 rounded object-cover"
/>
)}
<div className="flex-1">
<div className="font-medium text-sm">{post.title}</div>
<div className="text-xs text-muted-foreground">
{post.type} • {post.date}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
Data Structure
PostObjectItem
The component expects items to follow this structure:
interface PostObjectItem {
id: string | number
title: string
type?: string
status?: "publish" | "draft" | "private"
taxonomy?: string[]
featured_image?: string
excerpt?: string
date?: string
url?: string
}
Example Data
const posts: PostObjectItem[] = [
{
id: 1,
title: "Getting Started with React",
type: "post",
status: "publish",
taxonomy: ["React", "JavaScript"],
excerpt: "Learn the basics of React development...",
date: "2024-01-15",
url: "/posts/getting-started-react"
},
// ... more items
]
Async Loading
For large datasets, use the onLoad
prop to fetch data asynchronously:
const fetchPosts = async (filter: PostObjectFilter): Promise<PostObjectItem[]> => {
const response = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(filter)
})
return response.json()
}
<PostObject
onLoad={fetchPosts}
selectedItems={selected}
onChange={setSelected}
postTypes={["post", "page"]}
postStatuses={["publish", "draft"]}
showFilters={["search", "post_type"]}
/>
Filtering Options
Control which filters are displayed using the showFilters
prop:
// Show all filters
<PostObject showFilters={true} />
// Show only search
<PostObject showFilters={["search"]} />
// Show search and post type filters
<PostObject showFilters={["search", "post_type"]} />
// Hide all filters
<PostObject showFilters={false} />
Return Formats
Choose how selected items are returned:
// Return full objects (default)
<PostObject
returnFormat="object"
onChange={(items) => {
// items: PostObjectItem[]
console.log(items)
}}
/>
// Return only IDs
<PostObject
returnFormat="id"
onChange={(items) => {
// items: (string | number)[]
console.log(items) // [1, 2, 3]
}}
/>
Configuration Options
Single vs Multiple Selection
// Single selection (default)
<PostObject
multiple={false}
placeholder="Select a post..."
onChange={(items) => {
const post = items[0] // Single item
}}
/>
// Multiple selection
<PostObject
multiple={true}
placeholder="Select posts..."
sortable={true} // Enable drag-and-drop reordering
onChange={(items) => {
// Array of items
}}
/>
Allow Null Option
<PostObject
allowNull={true} // Show "Clear selection" option
required={false} // Not required
placeholder="Select a post (optional)..."
/>
API Reference
PostObject
Prop | Type | Default | Description |
---|---|---|---|
items | PostObjectItem[] | [] | Static items to display |
selectedItems | PostObjectItem[] | [] | Currently selected items |
onChange | (items: PostObjectItem[] | string[] | number[] | null) => void | - | Callback when selection changes |
onLoad | (filter: PostObjectFilter) => Promise<PostObjectItem[]> | - | Async data loading function |
multiple | boolean | false | Allow multiple selections |
allowNull | boolean | true | Allow clearing selections |
required | boolean | false | Whether field is required |
postTypes | string[] | [] | Available post types for filtering |
postStatuses | string[] | [] | Available post statuses for filtering |
taxonomies | string[] | [] | Available taxonomies for filtering |
showFilters | boolean | string[] | false | Which filters to show |
showPreview | boolean | false | Show preview links |
showFeaturedImage | boolean | false | Show featured images |
showExcerpt | boolean | false | Show item excerpts |
showDate | boolean | false | Show item dates |
sortable | boolean | false | Enable drag-and-drop sorting |
returnFormat | "object" | "id" | "object" | Format of returned data |
placeholder | string | "Select post..." | Dropdown placeholder text |
searchPlaceholder | string | "Search posts..." | Search input placeholder |
noItemsText | string | "No posts found" | Text when no items available |
loadingText | string | "Loading..." | Loading state text |
instructions | string | - | Help text above the field |
error | string | - | Error message to display |
variant | "default" | "error" | "default" | Visual variant |
size | "sm" | "md" | "lg" | "md" | Size variant |
Event Handlers
Prop | Type | Description |
---|---|---|
onSelect | (item: PostObjectItem) => void | Called when an item is selected |
onRemove | (item: PostObjectItem) => void | Called when an item is removed |
PostObjectItem Interface
interface PostObjectItem {
id: string | number // Unique identifier
title: string // Display title
type?: string // Post type (post, page, etc.)
status?: "publish" | "draft" | "private" // Publication status
taxonomy?: string[] // Categories, tags, etc.
featured_image?: string // Image URL
excerpt?: string // Short description
date?: string // Publication date
url?: string // Preview URL
}
PostObjectFilter Interface
interface PostObjectFilter {
search?: string // Search query
post_type?: string[] // Filter by post types
post_status?: string[] // Filter by post statuses
taxonomy?: string[] // Filter by taxonomies
}
Common Patterns
Content Management System
const [parentPage, setParentPage] = useState<PostObjectItem | null>(null)
<PostObject
items={pages}
selectedItems={parentPage ? [parentPage] : []}
onChange={(items) => {
const pageItems = items as PostObjectItem[]
setParentPage(pageItems[0] || null)
}}
multiple={false}
placeholder="Select parent page..."
showFilters={["search"]}
postTypes={["page"]}
allowNull
/>
Related Posts Selection
const [relatedPosts, setRelatedPosts] = useState<PostObjectItem[]>([])
<PostObject
onLoad={fetchPosts}
selectedItems={relatedPosts}
onChange={(items) => setRelatedPosts(items as PostObjectItem[])}
multiple={true}
placeholder="Select related posts..."
showFilters={["search", "post_type"]}
postTypes={["post", "tutorial", "guide"]}
sortable={true}
returnFormat="id"
/>
Featured Content Selector
const [featuredContent, setFeaturedContent] = useState<PostObjectItem | null>(null)
<PostObject
items={content}
selectedItems={featuredContent ? [featuredContent] : []}
onChange={(items) => {
const contentItems = items as PostObjectItem[]
setFeaturedContent(contentItems[0] || null)
}}
placeholder="Select featured content..."
showFilters={["search", "post_type", "post_status"]}
postTypes={["post", "page", "product"]}
postStatuses={["publish"]}
showExcerpt={true}
showFeaturedImage={true}
allowNull
/>
Accessibility
The Post Object component follows accessibility best practices:
- Keyboard Navigation - Full keyboard support with arrow keys and Enter
- Screen Reader Support - Proper ARIA labels and live regions
- Focus Management - Clear focus indicators and logical tab order
- High Contrast - Supports high contrast mode
- Combobox Pattern - Follows WAI-ARIA combobox guidelines
Comparison with Relationship Field
Feature | Post Object | Relationship |
---|---|---|
Interface | Dropdown/Select | Dual-panel |
Best for | Single or few selections | Multiple selections |
Screen space | Compact | More spacious |
Allow Null | ✅ Yes | ❌ No |
Search | In dropdown | Separate input |
Filtering | Optional filters | Always visible |
Use case | Quick selection | Relationship management |