Slide-outs
9 blocksSlide-out 01
Preview
npx nateui@latest add SlideoutAiChatDark
import { useEffect, useRef, useState } from 'react'
import Conversation from '@/components/composites/Conversation'
import Message from '@/components/composites/Message'
import PromptInput from '@/components/composites/PromptInput'
import type { PromptInputRef } from '@/components/composites/PromptInput'
import Button from '@/components/ui/Button'
import Drawer from '@/components/ui/Drawer'
import Dropdown from '@/components/ui/Dropdown'
import Tooltip from '@/components/ui/Tooltip'
import {
PiArrowsLeftRight,
PiArticle,
PiCaretDown,
PiCheckSquare,
PiListChecks,
PiMagnifyingGlass,
PiPlus,
PiTextAlignLeft,
PiX,
} from 'react-icons/pi'
type CopilotMessage = {
id: string
role: 'assistant' | 'user'
content: string
timestamp: string
streaming?: boolean
}
const createScriptedCopilotReply = (question: string) => {
const normalizedQuestion = question.toLowerCase()
if (
normalizedQuestion.includes('next') ||
normalizedQuestion.includes('checklist')
) {
return 'Start by naming the decision, listing the information still missing, and assigning one owner to each unresolved item. Keep the first pass to three actions so the review stays easy to scan.'
}
if (
normalizedQuestion.includes('compare') ||
normalizedQuestion.includes('option')
) {
return 'Compare the choices on impact, effort, and reversibility. Favor the option that removes the main dependency without creating another approval step.'
}
return 'The clearest path is to separate confirmed facts from open questions, then turn the most important uncertainty into one owned next step.'
}
const assetBase = 'https://statics.nateui.com/img'
const modelOptions = [
{ id: 'general', label: 'General model' },
{ id: 'fast', label: 'Fast model' },
{ id: 'reasoning', label: 'Reasoning model' },
]
const capabilityPrompts = [
{
label: 'Explain this page',
prompt: 'Explain the most important information on this page.',
icon: <PiArticle className="text-palette-blue" />,
},
{
label: 'Find next steps',
prompt: 'Identify the next steps from the current page.',
icon: <PiListChecks className="text-palette-cyan" />,
},
{
label: 'Compare options',
prompt: 'Compare the options visible on this page.',
icon: <PiArrowsLeftRight className="text-palette-purple" />,
},
{
label: 'Draft a checklist',
prompt: 'Draft a checklist from the current context.',
icon: <PiCheckSquare className="text-palette-emerald" />,
},
{
label: 'Review a decision',
prompt: 'Review the current decision and flag open questions.',
icon: <PiMagnifyingGlass className="text-palette-orange" />,
},
{
label: 'Summarize context',
prompt: 'Summarize the context I should know before taking action.',
icon: <PiTextAlignLeft className="text-palette-rose" />,
},
]
const getCurrentTimestamp = () =>
new Intl.DateTimeFormat('en', {
hour: 'numeric',
minute: '2-digit',
}).format(new Date())
export default function SlideoutAiChat() {
const [isOpen, setIsOpen] = useState(true)
const [selectedModel, setSelectedModel] = useState(modelOptions[0])
const [messages, setMessages] = useState<CopilotMessage[]>([])
const [inputValue, setInputValue] = useState('')
const [status, setStatus] = useState<'idle' | 'busy'>('idle')
const promptInputRef = useRef<PromptInputRef>(null)
const thinkingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const wordIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const activeReplyIdRef = useRef<string | null>(null)
useEffect(() => {
return () => {
if (thinkingTimeoutRef.current) clearTimeout(thinkingTimeoutRef.current)
if (wordIntervalRef.current) clearInterval(wordIntervalRef.current)
}
}, [])
const clearReplyTimers = () => {
if (thinkingTimeoutRef.current) {
clearTimeout(thinkingTimeoutRef.current)
thinkingTimeoutRef.current = null
}
if (wordIntervalRef.current) {
clearInterval(wordIntervalRef.current)
wordIntervalRef.current = null
}
}
const handleStop = () => {
clearReplyTimers()
const activeReplyId = activeReplyIdRef.current
if (activeReplyId) {
setMessages((current) =>
current.flatMap((message) => {
if (message.id !== activeReplyId) return [message]
if (!message.content.trim()) return []
return [
{
...message,
streaming: false,
timestamp: getCurrentTimestamp(),
},
]
}),
)
}
activeReplyIdRef.current = null
setStatus('idle')
}
const handleSubmit = (value: string) => {
const question = value.trim()
if (!question || status === 'busy') return
clearReplyTimers()
const turnId = Date.now()
const assistantId = `assistant-${turnId}`
const scriptedReply = createScriptedCopilotReply(question)
const replyWords = scriptedReply.split(/\s+/)
let wordIndex = 0
setMessages((current) => [
...current,
{
id: `user-${turnId}`,
role: 'user',
content: question,
timestamp: getCurrentTimestamp(),
},
{
id: assistantId,
role: 'assistant',
content: '',
timestamp: '',
streaming: true,
},
])
setInputValue('')
activeReplyIdRef.current = assistantId
setStatus('busy')
thinkingTimeoutRef.current = setTimeout(() => {
wordIntervalRef.current = setInterval(() => {
wordIndex += 1
const nextText = replyWords.slice(0, wordIndex).join(' ')
setMessages((current) =>
current.map((message) =>
message.id === assistantId
? { ...message, content: nextText }
: message,
),
)
if (wordIndex >= replyWords.length) {
if (wordIntervalRef.current) {
clearInterval(wordIntervalRef.current)
}
wordIntervalRef.current = null
setMessages((current) =>
current.map((message) =>
message.id === assistantId
? {
...message,
content: scriptedReply,
streaming: false,
timestamp: getCurrentTimestamp(),
}
: message,
),
)
activeReplyIdRef.current = null
setStatus('idle')
}
}, 55)
}, 650)
}
const handleCapability = (prompt: string) => {
setInputValue(prompt)
requestAnimationFrame(() => promptInputRef.current?.focus())
}
const handleNewChat = () => {
clearReplyTimers()
activeReplyIdRef.current = null
setMessages([])
setInputValue('')
setStatus('idle')
requestAnimationFrame(() => promptInputRef.current?.focus())
}
const hasConversation = messages.length > 0
const modelSelector = (
<Dropdown
activeKey={selectedModel.id}
placement="bottom-end"
onSelect={(eventKey) => {
const nextModel = modelOptions.find(
(model) => model.id === eventKey,
)
if (nextModel) setSelectedModel(nextModel)
}}
renderTitle={
<Button
type="button"
variant="ghost"
size="sm"
aria-label={`Select model: ${selectedModel.label}`}
className="min-w-0 max-w-52 font-normal"
>
<span className="flex items-center gap-2">
{selectedModel.label}
<PiCaretDown aria-hidden="true" />
</span>
</Button>
}
>
{modelOptions.map((model) => (
<Dropdown.Item key={model.id} eventKey={model.id}>
{model.label}
</Dropdown.Item>
))}
</Dropdown>
)
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={() => setIsOpen(true)}>
Open slideout
</Button>
<Drawer
aria-label="AI copilot chat"
isOpen={isOpen}
placement="right"
width={384}
closable={false}
contentClassName="max-w-[calc(100vw-1rem)]"
bodyClass="flex min-h-0 flex-1 flex-col overflow-hidden p-0"
footer={
<PromptInput
ref={promptInputRef}
value={inputValue}
status={status}
placeholder={
hasConversation
? 'Ask about this page'
: 'Ask about this workspace'
}
onChange={setInputValue}
onSubmit={handleSubmit}
onStop={handleStop}
>
<PromptInput.Toolbar>
<PromptInput.ToolbarStart>
<PromptInput.AttachButton />
</PromptInput.ToolbarStart>
<PromptInput.ToolbarEnd>
{modelSelector}
<PromptInput.Submit />
</PromptInput.ToolbarEnd>
</PromptInput.Toolbar>
</PromptInput>
}
footerClass="block"
shouldCloseOnEsc
shouldCloseOnOverlayClick
onClose={() => setIsOpen(false)}
>
<header className="flex shrink-0 items-center justify-between border-b p-4">
<h5>Ask Ai</h5>
<div className="flex items-center gap-2">
{
hasConversation && (
<Tooltip
title="Start a new chat"
placement="bottom"
wrapperClass="inline-flex"
>
<Button
type="button"
size="sm"
icon={<PiPlus />}
aria-label="Start new chat"
disabled={!hasConversation}
onClick={handleNewChat}
/>
</Tooltip>
)
}
<Button
type="button"
variant="ghost"
shape="circle"
size="sm"
icon={<PiX />}
aria-label="Close chat"
onClick={() => setIsOpen(false)}
/>
</div>
</header>
{hasConversation ? (
<div className="h-0 min-h-0 flex-1">
<Conversation autoScroll>
{messages.map((message) => (
<Message
key={message.id}
align={
message.role === 'user'
? 'end'
: 'start'
}
>
{message.streaming && !message.content ? (
<Message.TypeIndicator label="Copilot is thinking" />
) : (
<Message.Content
variant={
message.role === 'user'
? 'subtle'
: 'ghost'
}
className={
message.role === 'assistant'
? 'w-full max-w-none'
: undefined
}
>
{message.content}
</Message.Content>
)}
</Message>
))}
<Conversation.ScrollButton />
</Conversation>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center overflow-y-auto px-4">
<div className="flex w-full max-w-sm flex-col items-center text-center">
<div
aria-hidden="true"
className="flex flex-col items-center"
>
<img
alt=""
className="size-12 animate-spin object-contain [animation-duration:8s] motion-reduce:animate-none"
src={`${assetBase}/thumbs/misc/orb.png`}
/>
<div
className="mt-1 h-4 w-16 rounded-[50%] blur-md"
style={{
background:
'radial-gradient(ellipse, rgba(147, 51, 234, 0.85) 0%, rgba(168, 85, 247, 0.6) 40%, transparent 70%)',
}}
/>
</div>
<h5 className="mt-8">
What should we work on?
</h5>
<p className="mt-4 max-w-xs text-muted-foreground">
Ask about this page, shape a rough idea, or turn
scattered notes into a clear next step.
</p>
<div
role="group"
aria-label="Copilot capabilities"
className="mt-8 flex flex-wrap justify-center gap-2"
>
{capabilityPrompts.map((capability) => (
<Button
key={capability.label}
type="button"
size="sm"
variant="default"
icon={capability.icon}
className="font-normal"
onClick={() =>
handleCapability(capability.prompt)
}
>
{capability.label}
</Button>
))}
</div>
</div>
</div>
)}
</Drawer>
</main>
)
}
Slide-out 02
Preview
npx nateui@latest add SlideoutShareProjectDark
import { useEffect, useState } from 'react'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import CloseButton from '@/components/ui/CloseButton'
import Drawer from '@/components/ui/Drawer'
import Dropdown from '@/components/ui/Dropdown'
import Input from '@/components/ui/Input'
import { PiCaretDown, PiLink, PiUser } from 'react-icons/pi'
type PermissionRole = 'owner' | 'viewer' | 'editor'
type Participant = {
id: string
name: string
email: string
avatar: string
permissionRole: PermissionRole
}
type PermissionRoleOption = {
value: PermissionRole
label: string
}
type AccessOption = {
value: string
label: string
}
const shareUrl = 'https://nateui.com/share/project-01'
const assetBase = 'https://statics.nateui.com/img'
const accessOptions: AccessOption[] = [
{ value: 'Can view', label: 'Can view' },
{ value: 'Can edit', label: 'Can edit' },
{ value: 'Can delete', label: 'Can delete' },
{ value: 'Can invite', label: 'Can invite' },
]
const permissionRoleOptions: PermissionRoleOption[] = [
{ value: 'owner', label: 'Owner' },
{ value: 'viewer', label: 'Viewer' },
{ value: 'editor', label: 'Editor' },
]
const participants: Participant[] = [
{
id: 'member-01',
name: 'Morgan Lee',
email: 'morgan@example.com',
avatar: 'thumb-1.jpg',
permissionRole: 'owner',
},
{
id: 'member-02',
name: 'Alex Morgan',
email: 'alex@example.com',
avatar: 'thumb-2.jpg',
permissionRole: 'editor',
},
{
id: 'member-03',
name: 'Jamie Chen',
email: 'jamie@example.com',
avatar: 'thumb-3.jpg',
permissionRole: 'viewer',
},
{
id: 'member-04',
name: 'Taylor Brooks',
email: 'taylor@example.com',
avatar: 'thumb-4.jpg',
permissionRole: 'viewer',
},
{
id: 'member-05',
name: 'Casey Rivera',
email: 'casey@example.com',
avatar: 'thumb-5.jpg',
permissionRole: 'editor',
},
{
id: 'member-06',
name: 'Jordan Kim',
email: 'jordan@example.com',
avatar: 'thumb-6.jpg',
permissionRole: 'viewer',
},
{
id: 'member-07',
name: 'Riley Patel',
email: 'riley@example.com',
avatar: 'thumb-7.jpg',
permissionRole: 'viewer',
},
]
const getRoleLabel = (role: PermissionRole) =>
permissionRoleOptions.find((option) => option.value === role)?.label ??
role
export default function SlideoutShareProject() {
const [isOpen, setIsOpen] = useState(true)
const [selectedAccess, setSelectedAccess] = useState('Can view')
const [memberRoles, setMemberRoles] = useState<
Record<string, PermissionRole>
>(() =>
Object.fromEntries(
participants.map((participant) => [
participant.id,
participant.permissionRole,
]),
) as Record<string, PermissionRole>,
)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!copied) {
return
}
const copyFeedbackTimeout = setTimeout(() => setCopied(false), 2000)
return () => clearTimeout(copyFeedbackTimeout)
}, [copied])
const closeDrawer = () => setIsOpen(false)
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={() => setIsOpen(true)}>
Open share drawer
</Button>
<Drawer
aria-label="Share project"
isOpen={isOpen}
placement="right"
width={480}
closable={false}
bodyClass="p-0"
contentClassName="max-w-[calc(100vw-1rem)]"
footer={
<>
<Button
type="button"
variant="default"
onClick={closeDrawer}
>
Cancel
</Button>
<Button
type="button"
variant="solid"
onClick={closeDrawer}
>
Done
</Button>
</>
}
footerClass="justify-end gap-2"
shouldCloseOnEsc
shouldCloseOnOverlayClick
onClose={closeDrawer}
>
<header className="sticky top-0 z-sticky flex items-start justify-between gap-4 border-b p-4">
<div className="min-w-0">
<h5>Share project</h5>
<p className="text-muted-foreground">
Choose who can view and collaborate on this project.
</p>
</div>
<CloseButton
type="button"
aria-label="Close share project drawer"
onClick={closeDrawer}
/>
</header>
<div className="space-y-4 p-4">
<section
aria-labelledby="default-access-heading"
className="space-y-2 border-b pb-4"
>
<h6 id="default-access-heading">Default access</h6>
<div className="flex items-center justify-between gap-4 py-2">
<div className="flex min-w-0 items-center gap-2">
<Avatar
aria-hidden="true"
icon={<PiLink />}
size={32}
/>
<div className="min-w-0">
<div className="font-medium">
Anyone with the link
</div>
<div className="truncate text-xs text-muted-foreground">
Anyone on the internet with the link
</div>
</div>
</div>
<Dropdown
activeKey={selectedAccess}
menuClass="min-w-36"
placement="bottom-end"
renderTitle={
<Button
type="button"
variant="ghost"
size="sm"
iconAlignment="end"
icon={<PiCaretDown />}
aria-label={`Default access: ${selectedAccess}`}
>
{selectedAccess}
</Button>
}
onSelect={(eventKey) =>
setSelectedAccess(eventKey)
}
>
{accessOptions.map((option) => (
<Dropdown.Item
key={option.value}
eventKey={option.value}
>
{option.label}
</Dropdown.Item>
))}
</Dropdown>
</div>
<Input
aria-label="Share URL"
readOnly
value={shareUrl}
className="bg-accent focus-within:bg-accent"
suffix={
<Button
type="button"
variant="link"
size="sm"
className="px-0"
onClick={() => setCopied(true)}
>
{copied ? 'Copied' : 'Copy'}
</Button>
}
/>
<span
aria-atomic="true"
aria-live="polite"
className="sr-only"
>
{copied ? 'Copied' : ''}
</span>
</section>
<section
aria-labelledby="participants-heading"
className="space-y-2"
>
<h6 id="participants-heading">Participants</h6>
<ul className="space-y-2">
{participants.map((participant) => {
const memberRole =
memberRoles[participant.id] ??
participant.permissionRole
const owner = memberRole === 'owner'
return (
<li
key={participant.id}
className="flex items-center justify-between gap-4 py-2"
>
<div className="flex min-w-0 items-center gap-2">
<Avatar
aria-hidden="true"
src={`${assetBase}/avatars/${participant.avatar}`}
icon={<PiUser />}
size={32}
/>
<div className="min-w-0">
<div className="truncate font-medium">
{participant.name}
</div>
<div className="truncate text-xs text-muted-foreground">
{participant.email}
</div>
</div>
</div>
<Dropdown
activeKey={memberRole}
placement="bottom-end"
menuClass="min-w-36"
disabled={owner}
onSelect={(eventKey) => {
if (
eventKey === 'viewer' ||
eventKey === 'editor'
) {
setMemberRoles((current) => ({
...current,
[participant.id]: eventKey,
}))
}
}}
renderTitle={
<Button
type="button"
variant="ghost"
size="sm"
iconAlignment="end"
icon={<PiCaretDown />}
disabled={owner}
aria-label={`Role for ${participant.name}: ${getRoleLabel(memberRole)}`}
>
{getRoleLabel(memberRole)}
</Button>
}
>
{permissionRoleOptions.map((option) => (
<Dropdown.Item
key={option.value}
eventKey={option.value}
disabled={
option.value === 'owner'
}
>
{option.label}
</Dropdown.Item>
))}
</Dropdown>
</li>
)
})}
</ul>
</section>
</div>
</Drawer>
</main>
)
}
Slide-out 03
Preview
npx nateui@latest add SlideoutProductFiltersDark
import { useState } from 'react'
import Button from '@/components/ui/Button'
import Checkbox from '@/components/ui/Checkbox'
import CloseButton from '@/components/ui/CloseButton'
import Drawer from '@/components/ui/Drawer'
import Input from '@/components/ui/Input'
import Radio from '@/components/ui/Radio'
import Tag from '@/components/ui/Tag'
import Divider from '@/components/composites/Divider'
import RangeSlider, {
type RangeSliderValue,
} from '@/components/ui/Slider/RangeSlider'
type CategoryOption = {
value: string
label: string
}
type StockLevel = 'high' | 'medium' | 'low' | 'out-of-stock'
type StockLevelOption = {
value: StockLevel
label: string
}
type SortOption = {
value: string
label: string
}
type PriceInputIndex = 0 | 1
const priceRangeMin = 0
const priceRangeMax = 200
const priceRangeStep = 10
const initialPriceRange: RangeSliderValue = [20, 160]
const initialPriceInputValues: [string, string] = ['20', '160']
const priceHistogram = [
5, 14, 28, 46, 66, 82, 94, 100, 96, 90, 84, 76, 67, 58, 49, 41, 34, 28,
23, 18, 14, 10, 7, 4,
] as const
const histogramMaximum = Math.max(...priceHistogram)
const categoryOptions: CategoryOption[] = [
{ value: 'electronics', label: 'Electronics' },
{ value: 'home', label: 'Home' },
{ value: 'fashion', label: 'Fashion' },
{ value: 'beauty', label: 'Beauty' },
{ value: 'sports', label: 'Sports' },
{ value: 'books', label: 'Books' },
{ value: 'grocery', label: 'Grocery' },
{ value: 'toys', label: 'Toys' },
]
const initialSelectedCategories = [
'electronics',
'home',
'fashion',
'beauty',
]
const stockLevelOptions: StockLevelOption[] = [
{ value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' },
{ value: 'low', label: 'Low' },
{ value: 'out-of-stock', label: 'Out of Stock' },
]
const initialSelectedStockLevels: StockLevel[] = ['high', 'medium', 'low']
const sortOptions: SortOption[] = [
{ value: 'popularity', label: 'Popularity' },
{ value: 'rating', label: 'Rating' },
{ value: 'newest', label: 'Newest' },
{ value: 'delivery-fee', label: 'Delivery Fee' },
{ value: 'delivery-time', label: 'Delivery Time' },
]
const getHistogramBucketValue = (index: number) =>
priceRangeMin +
((index + 0.5) / priceHistogram.length) *
(priceRangeMax - priceRangeMin)
const clampAndSnapPrice = (value: number) =>
Math.min(
priceRangeMax,
Math.max(
priceRangeMin,
Math.round(value / priceRangeStep) * priceRangeStep,
),
)
export default function SlideoutProductFilters() {
const [isOpen, setIsOpen] = useState(true)
const [priceRange, setPriceRange] =
useState<RangeSliderValue>(initialPriceRange)
const [priceInputValues, setPriceInputValues] = useState<
[string, string]
>(initialPriceInputValues)
const [selectedCategories, setSelectedCategories] = useState(
initialSelectedCategories,
)
const [selectedStockLevels, setSelectedStockLevels] = useState<
StockLevel[]
>(initialSelectedStockLevels)
const [sortBy, setSortBy] = useState('rating')
const closeDrawer = () => setIsOpen(false)
const handlePriceRangeChange = (nextRange: RangeSliderValue) => {
setPriceRange(nextRange)
setPriceInputValues([
String(nextRange[0]),
String(nextRange[1]),
])
}
const updatePriceInput = (index: PriceInputIndex, value: string) => {
setPriceInputValues((current) => {
const next = [...current] as [string, string]
next[index] = value
return next
})
}
const commitPriceValue = (index: PriceInputIndex) => {
const rawValue = priceInputValues[index].trim()
const parsedValue = Number(rawValue)
if (!rawValue || !Number.isFinite(parsedValue)) {
setPriceInputValues((current) => {
const next = [...current] as [string, string]
next[index] = String(priceRange[index])
return next
})
return
}
const normalizedValue = clampAndSnapPrice(parsedValue)
const nextRange: RangeSliderValue =
index === 0
? [Math.min(normalizedValue, priceRange[1]), priceRange[1]]
: [priceRange[0], Math.max(normalizedValue, priceRange[0])]
setPriceRange(nextRange)
setPriceInputValues([
String(nextRange[0]),
String(nextRange[1]),
])
}
const toggleCategory = (value: string) => {
setSelectedCategories((current) =>
current.includes(value)
? current.filter((category) => category !== value)
: [...current, value],
)
}
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={() => setIsOpen(true)}>
Open filters
</Button>
<Drawer
aria-label="Product filters"
isOpen={isOpen}
placement="right"
width={400}
closable={false}
bodyClass="p-0"
contentClassName="max-w-[calc(100vw-1rem)]"
footer={
<>
<Button
type="button"
variant="default"
onClick={closeDrawer}
>
Cancel
</Button>
<Button
type="button"
variant="solid"
onClick={closeDrawer}
>
Apply
</Button>
</>
}
footerClass="justify-end gap-2"
shouldCloseOnEsc
shouldCloseOnOverlayClick
onClose={closeDrawer}
>
<header className="sticky top-0 z-sticky flex items-center justify-between gap-4 border-b p-4">
<h5>Filters</h5>
<CloseButton
type="button"
aria-label="Close product filters"
onClick={closeDrawer}
/>
</header>
<div className="p-4">
<section
aria-labelledby="product-filters-price-heading"
className="space-y-4"
>
<h6
id="product-filters-sort-heading"
className="text-base font-semibold"
>
Price Range
</h6>
<div
aria-hidden="true"
className="flex h-20 items-end gap-1"
>
{priceHistogram.map((height, index) => {
const bucketValue = getHistogramBucketValue(index)
const isInRange =
bucketValue >= priceRange[0] &&
bucketValue <= priceRange[1]
return (
<span
key={bucketValue}
className={`min-w-0 flex-1 ${isInRange ? 'bg-primary' : 'bg-palette-gray'}`}
style={{
height: `${(height / histogramMaximum) * 100}%`,
}}
/>
)
})}
</div>
<RangeSlider
aria-label="Price range"
min={priceRangeMin}
max={priceRangeMax}
step={priceRangeStep}
value={priceRange}
thumbArialLabelStart="Minimum price"
thumbAriaLabelEnd="Maximum price"
onChange={handlePriceRangeChange}
/>
<div className="grid grid-cols-2 gap-2">
<Input
aria-label="Minimum price"
type="text"
inputMode="numeric"
prefix="$"
value={priceInputValues[0]}
onChange={(event) =>
updatePriceInput(0, event.target.value)
}
onBlur={() => commitPriceValue(0)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commitPriceValue(0)
}
}}
/>
<Input
aria-label="Maximum price"
type="text"
inputMode="numeric"
prefix="$"
value={priceInputValues[1]}
onChange={(event) =>
updatePriceInput(1, event.target.value)
}
onBlur={() => commitPriceValue(1)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commitPriceValue(1)
}
}}
/>
</div>
</section>
<Divider className="my-4" />
<section
aria-labelledby="product-filters-categories-heading"
className="space-y-4"
>
<h6
id="product-filters-sort-heading"
className="text-base font-semibold"
>
Categories
</h6>
<div
aria-label="Categories"
className="flex flex-wrap gap-2"
role="group"
>
{categoryOptions.map((category) => {
const isSelected = selectedCategories.includes(
category.value,
)
return (
<Tag
key={category.value}
role="button"
aria-pressed={isSelected}
className={
isSelected
? 'cursor-pointer border-palette-blue bg-palette-blue-soft text-palette-blue-soft-foreground'
: 'cursor-pointer '
}
onClick={() =>
toggleCategory(category.value)
}
>
{category.label}
</Tag>
)
})}
</div>
</section>
<Divider className="my-4" />
<section
aria-labelledby="product-filters-stock-heading"
className="space-y-4"
>
<h6
id="product-filters-sort-heading"
className="text-base font-semibold"
>
Stock Level
</h6>
<Checkbox.Group
aria-label="Stock level"
value={selectedStockLevels}
className="flex w-full flex-col gap-2"
onChange={(value) =>
setSelectedStockLevels(value as StockLevel[])
}
>
{stockLevelOptions.map((option) => (
<Checkbox
key={option.value}
value={option.value}
className="w-full flex-row-reverse justify-between rounded-control"
>
{option.label}
</Checkbox>
))}
</Checkbox.Group>
</section>
<Divider className="my-4" />
<section
aria-labelledby="product-filters-sort-heading"
className="space-y-4"
>
<h6
id="product-filters-sort-heading"
className="text-base font-semibold"
>
Sort By
</h6>
<Radio.Group
aria-label="Sort by"
name="product-filter-sort"
value={sortBy}
className="flex w-full flex-col gap-2"
onChange={(value) => setSortBy(value)}
>
{sortOptions.map((option) => (
<Radio
key={option.value}
value={option.value}
className="w-full flex-row-reverse justify-between rounded-control"
>
{option.label}
</Radio>
))}
</Radio.Group>
</section>
</div>
</Drawer>
</main>
)
}
Slide-out 04
Preview
npx nateui@latest add SlideoutCreateProjectDark
import { useEffect, useRef, useState, type ReactNode } from 'react'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import CloseButton from '@/components/ui/CloseButton'
import DatePicker from '@/components/ui/DatePicker'
import Drawer from '@/components/ui/Drawer'
import { Form } from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Select from '@/components/ui/Select'
import Upload from '@/components/ui/Upload'
import IconFrame from '@/components/composites/IconFrame'
import classNames from '@/utils/classNames'
import {
PiArchiveFill,
PiCheckCircleFill,
PiImageSquare,
PiProhibitFill,
PiSelectionPlus,
PiTimerFill,
} from 'react-icons/pi'
type MemberOption = {
value: string
label: string
img: string
email: string
}
type StatusOption = {
value: string
label: string
color: string
icon: ReactNode
}
const assetBase = 'https://statics.nateui.com/img'
const priorityMap: Record<string, { color: string }> = {
Low: { color: 'bg-success' },
Medium: { color: 'bg-warning' },
High: { color: 'bg-destructive' },
}
const statusMap: Record<
string,
{ color: string; label: string; icon: ReactNode }
> = {
active: {
label: 'Active',
color: 'text-info',
icon: <PiTimerFill className="text-base" />,
},
onHold: {
label: 'On Hold',
color: 'text-destructive',
icon: <PiProhibitFill />,
},
archived: {
label: 'Archived',
color: 'text-warning',
icon: <PiArchiveFill />,
},
completed: {
label: 'Completed',
color: 'text-success',
icon: <PiCheckCircleFill />,
},
}
const priorityOptions = Object.keys(priorityMap).map((value) => ({
value,
label: value,
}))
const statusOptions: StatusOption[] = Object.entries(statusMap).map(
([value, option]) => ({
value,
label: option.label,
color: option.color,
icon: option.icon,
}),
)
const memberOptions: MemberOption[] = [
{
value: 'member-01',
label: 'Morgan Lee',
email: 'morgan@example.com',
img: `${assetBase}/avatars/thumb-1.jpg`,
},
{
value: 'member-02',
label: 'Alex Morgan',
email: 'alex@example.com',
img: `${assetBase}/avatars/thumb-2.jpg`,
},
{
value: 'member-03',
label: 'Jamie Chen',
email: 'jamie@example.com',
img: `${assetBase}/avatars/thumb-3.jpg`,
},
]
const requiredMark = <span className="text-destructive">*</span>
const initialLogoUrl = `${assetBase}/thumbs/projects/img-1.jpg`
const initialSelectedMembers = memberOptions.slice(0, 2)
const initialDueDate = new Date(2026, 8, 15)
const priorityLabelId = 'slideout-create-project-priority-label'
const statusLabelId = 'slideout-create-project-status-label'
const assigneeInputId = 'slideout-create-project-assignee-input'
const dueDateLabelId = 'slideout-create-project-due-date-label'
const dueDateInputId = 'slideout-create-project-due-date'
export default function SlideoutCreateProject() {
const [isOpen, setIsOpen] = useState(true)
const [logoUrl, setLogoUrl] = useState(initialLogoUrl)
const [projectName, setProjectName] = useState('Project 01')
const [description, setDescription] = useState(
'Coordinate the next delivery cycle.',
)
const [priority, setPriority] = useState('High')
const [status, setStatus] = useState('active')
const [assignees, setAssignees] =
useState<MemberOption[]>(initialSelectedMembers)
const [dueDate, setDueDate] = useState<Date | null>(initialDueDate)
const uploadedLogoUrlRef = useRef<string | null>(null)
const dueDateInputRef = useRef<HTMLInputElement>(null)
const setDueDateInputRef = (input: HTMLInputElement | null) => {
dueDateInputRef.current = input
if (input) {
input.id = dueDateInputId
input.setAttribute('aria-labelledby', dueDateLabelId)
}
}
useEffect(() => {
return () => {
if (uploadedLogoUrlRef.current) {
URL.revokeObjectURL(uploadedLogoUrlRef.current)
}
}
}, [])
const closeDrawer = () => setIsOpen(false)
const beforeUpload = (files: FileList | null) => {
let valid: string | boolean = true
const allowedFileType = ['image/jpeg', 'image/png']
if (files) {
for (const file of files) {
if (!allowedFileType.includes(file.type)) {
valid = 'Please upload a .jpeg or .png file!'
}
}
}
return valid
}
const handleLogoChange = (file: File) => {
if (uploadedLogoUrlRef.current) {
URL.revokeObjectURL(uploadedLogoUrlRef.current)
}
const nextLogoUrl = URL.createObjectURL(file)
uploadedLogoUrlRef.current = nextLogoUrl
setLogoUrl(nextLogoUrl)
}
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={() => setIsOpen(true)}>
Open create drawer
</Button>
<Drawer
aria-label="Create project"
isOpen={isOpen}
placement="right"
width={480}
closable={false}
bodyClass="p-0"
contentClassName="max-w-[calc(100vw-1rem)]"
footer={
<>
<Button
type="button"
variant="default"
onClick={closeDrawer}
>
Cancel
</Button>
<Button
type="button"
variant="solid"
onClick={closeDrawer}
>
Create
</Button>
</>
}
footerClass="justify-end gap-2"
shouldCloseOnEsc
shouldCloseOnOverlayClick
onClose={closeDrawer}
>
<header className="sticky top-0 z-sticky flex items-start justify-between gap-4 border-b bg-card p-4">
<div className="min-w-0">
<IconFrame variant="layered">
<PiSelectionPlus
aria-hidden="true"
className="text-xl"
/>
</IconFrame>
<h5 id="slideout-create-project-title" className="mt-4">
Create Project
</h5>
<p className="text-muted-foreground">
Get started quickly & keep everything organized
</p>
</div>
<CloseButton
type="button"
aria-label="Close create project drawer"
onClick={closeDrawer}
/>
</header>
<div className="p-4">
<Form
aria-labelledby="slideout-create-project-title"
containerClassName="flex flex-col gap-4"
onSubmit={(event) => event.preventDefault()}
>
<Form.Field className="mb-0">
<Upload
accept="image/jpeg,image/png"
beforeUpload={beforeUpload}
showList={false}
uploadLimit={1}
onChange={handleLogoChange}
>
<div className="flex cursor-pointer items-center gap-2">
{logoUrl ? (
<Avatar shape="round"
src={logoUrl}
size="lg"
alt="Project logo"
/>
) : (
<div className="relative flex h-12 w-12 items-center justify-center overflow-hidden rounded-control-sm border border-dashed bg-accent text-foreground">
<PiImageSquare
aria-hidden="true"
className="text-xl"
/>
</div>
)}
<div>
<div className="font-medium text-foreground">
Upload a logo
</div>
<span className="text-xs text-muted-foreground">
We only support PNGs and JPEGs
</span>
</div>
</div>
</Upload>
</Form.Field>
<Form.Field
label="Name"
extra={requiredMark}
htmlFor="slideout-create-project-name"
className="mb-0"
>
<Input
id="slideout-create-project-name"
placeholder="Enter Project Name"
value={projectName}
onChange={(event) =>
setProjectName(event.target.value)
}
/>
</Form.Field>
<Form.Field
label="Description"
htmlFor="slideout-create-project-description"
className="mb-0"
>
<Input
id="slideout-create-project-description"
placeholder="Enter Description"
textArea
value={description}
onChange={(event) =>
setDescription(event.target.value)
}
/>
</Form.Field>
<Form.Field
label="Priority"
extra={requiredMark}
labelId={priorityLabelId}
className="mb-0"
>
<Select
inputId={priorityLabelId}
options={priorityOptions}
value={priorityOptions.find(
(option) => option.value === priority,
)}
onChange={(option) =>
setPriority(option.value)
}
placeholder="Select priority"
customInputDisplay={(selectedItem) => (
<Select.ValueWithPrefix
label={selectedItem?.label}
prefix={
selectedItem && (
<span
className={classNames(
'h-3 w-3 rounded-sm',
priorityMap[
selectedItem.label
]?.color,
)}
/>
)
}
/>
)}
customOption={({
option,
selected,
CheckIcon,
}) => (
<Select.OptionWithPrefix
selected={selected}
checkIcon={CheckIcon}
label={option.label}
prefix={
<span
className={classNames(
'h-3 w-3 rounded-sm',
priorityMap[option.label]
?.color,
)}
/>
}
/>
)}
/>
</Form.Field>
<Form.Field
label="Status"
extra={requiredMark}
labelId={statusLabelId}
className="mb-0"
>
<Select
inputId={statusLabelId}
options={statusOptions}
value={statusOptions.find(
(option) => option.value === status,
)}
onChange={(option) => setStatus(option.value)}
placeholder="Select status"
customInputDisplay={(selectedItem) => (
<Select.ValueWithPrefix
label={selectedItem?.label}
prefix={
selectedItem && (
<span
className={classNames(
'text-base',
statusMap[
selectedItem.value
]?.color,
)}
>
{
statusMap[
selectedItem.value
]?.icon
}
</span>
)
}
/>
)}
customOption={({
option,
selected,
CheckIcon,
}) => (
<Select.OptionWithPrefix
selected={selected}
checkIcon={CheckIcon}
label={option.label}
prefix={
<span
className={classNames(
'text-base',
option.color,
)}
>
{statusMap[option.value]?.icon}
</span>
}
/>
)}
/>
</Form.Field>
<Form.Field
label="Assignee"
extra={requiredMark}
htmlFor={assigneeInputId}
className="mb-0"
>
<Select.Multi
inputId={assigneeInputId}
options={memberOptions}
value={assignees}
onChange={setAssignees}
customOption={({
option,
selected,
CheckIcon,
}) => (
<Select.OptionWithPrefix
selected={selected}
checkIcon={CheckIcon}
label={option.label}
prefix={
<Avatar
aria-hidden="true"
size={20}
src={option.img}
alt=""
/>
}
/>
)}
customLabel={(item) => (
<div className="flex items-center gap-1">
<Avatar
aria-hidden="true"
size={20}
src={item.img}
alt=""
/>
<span>{item.label}</span>
</div>
)}
/>
</Form.Field>
<Form.Field
label="Due Date"
extra={requiredMark}
labelId={dueDateLabelId}
htmlFor={dueDateInputId}
className="mb-0"
>
<DatePicker
ref={setDueDateInputRef}
placeholder="Select Due Date"
value={dueDate}
onChange={setDueDate}
clearable={false}
/>
</Form.Field>
</Form>
</div>
</Drawer>
</main>
)
}
Slide-out 05
Preview
npx nateui@latest add SlideoutClientDetailsDark
import { useEffect, useRef, useState, type ReactNode } from 'react'
import dayjs from 'dayjs'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import CloseButton from '@/components/ui/CloseButton'
import Collapsible from '@/components/ui/Collapsible'
import Drawer from '@/components/ui/Drawer'
import Scroll from '@/components/ui/Scroll'
import Tabs from '@/components/ui/Tabs'
import Tag from '@/components/ui/Tag'
import Timeline from '@/components/ui/Timeline'
import InfoBar from '@/components/composites/InfoBar'
import {
PiBriefcase,
PiBuilding,
PiCheck,
PiChecks,
PiCircle,
PiClock,
PiCopySimple,
PiDeviceMobile,
PiEnvelope,
PiFileText,
PiMapPin,
PiNote,
PiPaperclip,
PiPhone,
PiCaretRight,
PiPulse,
PiSpinner,
PiUser,
PiUserCheck,
PiUsersThree,
} from 'react-icons/pi'
type DetailField = {
icon: ReactNode
label: string
value: ReactNode
}
type Note = {
id: string
author: string
avatar: string
timestamp: string
body: string
}
type ProbabilityLevel = 'low' | 'medium' | 'high'
type ActivityEvent = {
type: string
dateTime: number
userName: string
userImg?: string
record?: string
recordType?: string
field?: string
from?: string
to?: string
status?: number
comment?: string
tags?: { value: string; label: string }[]
files?: string[]
assignee?: string
}
type ActivityGroup = {
id: string
date: number
events: ActivityEvent[]
}
const assetBase = 'https://statics.nateui.com/img'
const client = {
name: 'Jordan Lee',
firstName: 'Jordan',
lastName: 'Lee',
title: 'Client Partner',
email: 'jordan@example.com',
phone: '+1 (503) 555-0142',
mobile: '+1 (503) 555-0142',
company: 'Northstar Studio',
description: 'Key stakeholder driving the engagement.',
probability: 'High',
owner: 'Morgan Lee',
ownerAvatar: 'thumb-5.jpg',
location: 'Portland, Oregon, USA',
lastContacted: 'Aug 14, 2026',
addedAt: 'Aug 6, 2026',
avatar: 'thumb-4.jpg',
}
const probabilityLevelMap: Record<string, ProbabilityLevel> = {
Low: 'low',
Medium: 'medium',
High: 'high',
}
const detailFieldGroups: DetailField[][] = [
[
{
icon: <PiUser aria-hidden="true" />,
label: 'First Name',
value: client.firstName,
},
{
icon: <PiUser aria-hidden="true" />,
label: 'Last Name',
value: client.lastName,
},
{
icon: <PiBriefcase aria-hidden="true" />,
label: 'Job Title',
value: client.title,
},
{
icon: <PiEnvelope aria-hidden="true" />,
label: 'Email',
value: (
<a
href={`mailto:${client.email}`}
className="break-all rounded-tag border bg-card px-2 py-0.5 text-xs font-semibold text-card-foreground underline underline-offset-2"
>
{client.email}
</a>
),
},
{
icon: <PiPhone aria-hidden="true" />,
label: 'Phone',
value: client.phone,
},
{
icon: <PiDeviceMobile aria-hidden="true" />,
label: 'Mobile',
value: client.mobile,
},
],
[
{
icon: <PiBuilding aria-hidden="true" />,
label: 'Company',
value: client.company,
},
{
icon: <PiFileText aria-hidden="true" />,
label: 'Description',
value: client.description,
},
],
[
{
icon: <PiPulse aria-hidden="true" />,
label: 'Probability',
value: (
<div className="flex items-center gap-2">
<InfoBar level={probabilityLevelMap[client.probability]} />
<span className="font-medium">{client.probability}</span>
</div>
),
},
{
icon: <PiUserCheck aria-hidden="true" />,
label: 'Account Owner',
value: (
<div className="flex items-center gap-2">
<Avatar
src={`${assetBase}/avatars/${client.ownerAvatar}`}
alt=""
aria-hidden="true"
size={20}
icon={<PiUser />}
/>
<span>{client.owner}</span>
</div>
),
},
{
icon: <PiMapPin aria-hidden="true" />,
label: 'Location',
value: client.location,
},
{
icon: <PiClock aria-hidden="true" />,
label: 'Last Contacted',
value: client.lastContacted,
},
],
]
const UPDATE_STATUS = 'UPDATE-STATUS'
const COMMENT = 'COMMENT'
const ADD_TAGS = 'ADD-TAGS'
const ADD_FILES = 'ADD-FILES'
const COMMENT_MENTION = 'COMMENT-MENTION'
const ASSIGN_TASK = 'ASSIGN-TASK'
const CREATE_TASK = 'CREATE-TASK'
const LOG_CALL = 'LOG-CALL'
const SCHEDULE_MEETING = 'SCHEDULE-MEETING'
const UPDATE_FIELD = 'UPDATE-FIELD'
const statusMap: Record<
number,
{
label: string
icon: ReactNode
}
> = {
0: {
label: 'Completed',
icon: (
<span className="flex size-2 items-center justify-center rounded-full bg-success text-success-foreground">
<PiChecks className="text-xs" aria-hidden="true" />
</span>
),
},
1: {
label: 'In progress',
icon: (
<span className="flex size-2 items-center justify-center rounded-full bg-info text-info-foreground">
<PiSpinner className="text-xs" aria-hidden="true" />
</span>
),
},
2: {
label: 'Open',
icon: (
<span className="flex size-2 items-center justify-center rounded-full bg-warning text-warning-foreground">
<PiCircle className="text-xs" aria-hidden="true" />
</span>
),
},
}
const tagColorMap: Record<string, string> = {
followup: 'bg-palette-blue',
account: 'bg-palette-emerald',
priority: 'bg-palette-orange',
}
const activityGroups: ActivityGroup[] = [
{
id: 'activity-01',
date: dayjs('2026-08-14').unix(),
events: [
{
type: UPDATE_FIELD,
dateTime: dayjs('2026-08-14 10:15').unix(),
userName: 'Morgan Lee',
userImg: `${assetBase}/avatars/thumb-5.jpg`,
field: 'probability',
from: 'Medium',
to: 'High',
},
{
type: COMMENT,
dateTime: dayjs('2026-08-14 09:40').unix(),
userName: 'Alex Morgan',
userImg: `${assetBase}/avatars/thumb-6.jpg`,
comment:
'Reviewed the client details and shared the updated service overview.',
},
{
type: ADD_TAGS,
dateTime: dayjs('2026-08-14 09:20').unix(),
userName: 'Morgan Lee',
userImg: `${assetBase}/avatars/thumb-5.jpg`,
tags: [
{ value: 'followup', label: 'Follow-up' },
{ value: 'account', label: 'Account' },
],
},
],
},
{
id: 'activity-02',
date: dayjs('2026-08-12').unix(),
events: [
{
type: ADD_FILES,
dateTime: dayjs('2026-08-12 14:30').unix(),
userName: 'Alex Morgan',
userImg: `${assetBase}/avatars/thumb-6.jpg`,
files: ['service-overview.pdf', 'client-brief.pdf'],
record: 'this client',
recordType: 'contact',
},
{
type: ASSIGN_TASK,
dateTime: dayjs('2026-08-12 11:10').unix(),
userName: 'Morgan Lee',
userImg: `${assetBase}/avatars/thumb-5.jpg`,
record: 'Confirm renewal timeline',
assignee: 'Alex Morgan',
},
{
type: SCHEDULE_MEETING,
dateTime: dayjs('2026-08-12 09:00').unix(),
userName: 'Alex Morgan',
userImg: `${assetBase}/avatars/thumb-6.jpg`,
record: 'Quarterly review',
recordType: 'meeting',
},
],
},
]
const notes: Note[] = [
{
id: 'note-01',
author: 'Morgan Lee',
avatar: 'thumb-5.jpg',
timestamp: 'Today, 10:15 AM',
body: 'Follow up on the onboarding timeline after the next project review.',
},
{
id: 'note-02',
author: 'Alex Morgan',
avatar: 'thumb-6.jpg',
timestamp: 'Yesterday, 3:40 PM',
body: 'Shared the updated service overview and confirmed the primary contact.',
},
{
id: 'note-03',
author: 'Morgan Lee',
avatar: 'thumb-5.jpg',
timestamp: 'June 20, 2026',
body: 'Client prefers concise updates with a short summary before each meeting.',
},
]
const renderDetailField = (field: DetailField) => (
<div key={field.label} className="flex items-start gap-3">
<div className="flex w-32 shrink-0 items-center gap-2 text-sm text-muted-foreground">
<span className="text-base" aria-hidden="true">
{field.icon}
</span>
<span>{field.label}</span>
</div>
<div className="min-w-0 flex-1 break-words text-sm text-foreground">
{field.value}
</div>
</div>
)
const ActivityAvatar = ({ event }: { event: ActivityEvent }) => (
<Avatar
src={event.userImg}
alt=""
aria-hidden="true"
size={24}
icon={<PiUser />}
/>
)
const DateDivider = ({ value }: { value: number }) => (
<div className="mb-4">
<time className="text-sm font-semibold text-foreground">
{dayjs.unix(value).format('dddd, DD MMMM')}
</time>
</div>
)
const UnixDateTime = ({ value }: { value: number }) => (
<time
dateTime={new Date(value * 1000).toISOString()}
className="font-medium text-muted-foreground"
>
{dayjs.unix(value).format('hh:mm A')}
</time>
)
const HighlightedText = ({
children,
}: {
children: ReactNode
}) => <span className="font-medium text-foreground">{children}</span>
const CommentBox = ({ comment }: { comment?: string }) => {
if (!comment) {
return null
}
return (
<div className="mt-4 rounded-card border bg-card px-4 py-3 text-sm leading-relaxed text-foreground">
{comment}
</div>
)
}
const ContactTagList = ({ event }: { event: ActivityEvent }) => {
if (!event.tags?.length) {
return null
}
return (
<span className="inline-flex flex-wrap items-center gap-1">
{event.tags.map((tag) => (
<Tag
key={tag.value}
prefix
prefixClass={tagColorMap[tag.value] ?? 'bg-muted-foreground'}
className="bg-transparent px-1.5 py-0.5"
>
{tag.label}
</Tag>
))}
</span>
)
}
const FileListInline = ({ event }: { event: ActivityEvent }) => {
if (!event.files?.length) {
return null
}
return (
<span className="inline-flex flex-wrap items-center gap-1">
{event.files.map((file, index) => (
<span key={file} className="inline-flex items-center gap-0.5">
<PiPaperclip className="text-base" aria-hidden="true" />
<HighlightedText>{file}</HighlightedText>
{index < event.files!.length - 1 && <span>,</span>}
</span>
))}
</span>
)
}
const ActivityEvent = ({ event }: { event: ActivityEvent }) => {
const record = event.record ?? 'this client'
const recordType = event.recordType ?? 'record'
const status = statusMap[event.status ?? 2] ?? statusMap[2]
switch (event.type) {
case UPDATE_STATUS:
return (
<div className="inline-flex flex-wrap items-center gap-1 text-sm">
<HighlightedText>{event.userName}</HighlightedText>
<span className="text-muted-foreground">updated</span>
<span className="text-muted-foreground">{recordType}</span>
<HighlightedText>{record}</HighlightedText>
<span className="text-muted-foreground">status to</span>
<span className="inline-flex items-center gap-1">
{status.icon}
<HighlightedText>{status.label}</HighlightedText>
</span>
<UnixDateTime value={event.dateTime} />
</div>
)
case COMMENT:
return (
<>
<div className="inline-flex flex-wrap items-center gap-1 text-sm">
<HighlightedText>{event.userName}</HighlightedText>
<span className="text-muted-foreground">
added a note on
</span>
<HighlightedText>this client</HighlightedText>
<UnixDateTime value={event.dateTime} />
</div>
<CommentBox comment={event.comment} />
</>
)
case COMMENT_MENTION:
return (
<>
<div className="inline-flex flex-wrap items-center gap-1 text-sm">
<HighlightedText>{event.userName}</HighlightedText>
<span className="text-muted-foreground">
mentioned you in a client note
</span>
<UnixDateTime value={event.dateTime} />
</div>
<CommentBox comment={event.comment} />
</>
)
case ADD_TAGS:
return (
<div className="inline-flex flex-wrap items-center gap-1 text-sm">
<HighlightedText>{event.userName}</HighlightedText>
<span className="text-muted-foreground">
added client tags
</span>
<ContactTagList event={event} />
<UnixDateTime value={event.dateTime} />
</div>
)
case ADD_FILES:
return (
<div className="inline-flex flex-wrap items-center gap-1 text-sm">
<HighlightedText>{event.userName}</HighlightedText>
<span className="text-muted-foreground">attached</span>
<FileListInline event={event} />
<span className="text-muted-foreground">to</span>
<span className="text-muted-foreground">{recordType}</span>
<HighlightedText>{record}</HighlightedText>
<UnixDateTime value={event.dateTime} />
</div>
)
case ASSIGN_TASK:
return (
<div className="inline-flex flex-wrap items-center gap-1 text-sm">
<HighlightedText>{event.userName}</HighlightedText>
<span className="text-muted-foreground">assigned task</span>
<HighlightedText>{record}</HighlightedText>
<span className="text-muted-foreground">to</span>
<HighlightedText>{event.assignee ?? 'Unassigned'}</HighlightedText>
<UnixDateTime value={event.dateTime} />
</div>
)
case CREATE_TASK:
return (
<div className="inline-flex flex-wrap items-center gap-1 text-sm">
<HighlightedText>{event.userName}</HighlightedText>
<span className="text-muted-foreground">created task</span>
<HighlightedText>{record}</HighlightedText>
<UnixDateTime value={event.dateTime} />
</div>
)
case LOG_CALL:
return (
<>
<div className="inline-flex flex-wrap items-center gap-1 text-sm">
<HighlightedText>{event.userName}</HighlightedText>
<span className="text-muted-foreground">
logged a call with
</span>
<HighlightedText>{record}</HighlightedText>
<UnixDateTime value={event.dateTime} />
</div>
<CommentBox comment={event.comment} />
</>
)
case SCHEDULE_MEETING:
return (
<div className="inline-flex flex-wrap items-center gap-1 text-sm">
<HighlightedText>{event.userName}</HighlightedText>
<span className="text-muted-foreground">scheduled</span>
<span className="text-muted-foreground">{recordType}</span>
<HighlightedText>{record}</HighlightedText>
<UnixDateTime value={event.dateTime} />
</div>
)
case UPDATE_FIELD:
return (
<div className="inline-flex flex-wrap items-center gap-1 text-sm">
<HighlightedText>{event.userName}</HighlightedText>
<span className="text-muted-foreground">updated</span>
<HighlightedText>{event.field ?? 'field'}</HighlightedText>
<span className="text-muted-foreground">from</span>
<HighlightedText>{event.from ?? 'Unknown'}</HighlightedText>
<span className="text-muted-foreground">to</span>
<HighlightedText>{event.to ?? 'Unknown'}</HighlightedText>
<UnixDateTime value={event.dateTime} />
</div>
)
default:
return (
<div className="inline-flex flex-wrap items-center gap-1 text-sm">
<HighlightedText>{event.userName}</HighlightedText>
<span className="text-muted-foreground">
logged an activity
</span>
<UnixDateTime value={event.dateTime} />
</div>
)
}
}
const ActivityTimeline = () => (
<div>
{activityGroups.map((group, groupIndex) => (
<div
key={`${group.id}-${groupIndex}`}
className="mb-8 last:mb-0"
>
{group.events.length > 0 && <DateDivider value={group.date} />}
<Timeline>
{group.events.length === 0 ? (
<Timeline.Item>No activities</Timeline.Item>
) : (
group.events.map((event, index) => (
<Timeline.Item
key={`${group.id}-${event.type}-${index}`}
media={<ActivityAvatar event={event} />}
>
<div className="mt-0.5">
<ActivityEvent event={event} />
</div>
</Timeline.Item>
))
)}
</Timeline>
</div>
))}
</div>
)
export default function SlideoutClientDetails() {
const [isOpen, setIsOpen] = useState(true)
const [activeTab, setActiveTab] = useState('details')
const [showAllValues, setShowAllValues] = useState(false)
const [copied, setCopied] = useState(false)
const copyResetTimerRef = useRef<number | null>(null)
useEffect(() => {
return () => {
if (copyResetTimerRef.current !== null) {
window.clearTimeout(copyResetTimerRef.current)
}
}
}, [])
const handleCopyName = () => {
const clipboardWrite = navigator.clipboard?.writeText(client.name)
void clipboardWrite?.catch(() => undefined)
setCopied(true)
if (copyResetTimerRef.current !== null) {
window.clearTimeout(copyResetTimerRef.current)
}
copyResetTimerRef.current = window.setTimeout(() => {
setCopied(false)
}, 2000)
}
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={() => setIsOpen(true)}>
Open client details
</Button>
<Drawer
isOpen={isOpen}
onClose={() => setIsOpen(false)}
placement="right"
width={520}
closable={false}
bodyClass="flex min-h-0 flex-1 flex-col overflow-hidden p-0"
contentClassName="max-w-[calc(100vw-1rem)]"
aria-label="Client details"
shouldCloseOnEsc
shouldCloseOnOverlayClick
>
<header className="flex shrink-0 items-center justify-between gap-2 p-4">
<h5>Client details</h5>
<CloseButton
aria-label="Close client details"
onClick={() => setIsOpen(false)}
/>
</header>
<Tabs
value={activeTab}
onChange={setActiveTab}
className="flex min-h-0 flex-1 flex-col"
>
<Tabs.TabList className="shrink-0 bg-card px-2">
<Tabs.TabNav
value="details"
icon={<PiUsersThree aria-hidden="true" />}
>
Details
</Tabs.TabNav>
<Tabs.TabNav
value="notes"
icon={<PiNote aria-hidden="true" />}
>
Notes
</Tabs.TabNav>
</Tabs.TabList>
<Scroll.FlexSize
flexRootClassName="min-h-0 flex-1"
className="h-full"
scrollbars="vertical"
type="auto"
>
<Tabs.TabContent value="details">
<Collapsible defaultOpen className="border-b">
<Collapsible.Trigger>
<span className="text-base font-semibold">Record details</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div className="flex flex-col gap-4 px-4 pb-4">
<div className="flex items-center gap-3">
<Avatar
src={`${assetBase}/avatars/${client.avatar}`}
alt=""
aria-hidden="true"
size="md"
icon={<PiUser />}
/>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1">
<p className="truncate font-semibold text-foreground">
{client.name}
</p>
<Button
type="button"
variant="ghost"
shape="circle"
size="sm"
icon={
copied ? (
<PiCheck aria-hidden="true" />
) : (
<PiCopySimple aria-hidden="true" />
)
}
aria-label="Copy client name"
onClick={handleCopyName}
/>
</div>
</div>
</div>
{detailFieldGroups[0].map(renderDetailField)}
{showAllValues &&
detailFieldGroups.slice(1).map((group, index) => (
<div
key={`detail-group-${index + 2}`}
className="flex flex-col gap-4"
>
{group.map(renderDetailField)}
</div>
))}
<Button
type="button"
variant="link"
size="sm"
className="self-start px-0"
icon={<PiCaretRight aria-hidden="true" />}
iconAlignment="end"
onClick={() =>
setShowAllValues((previous) => !previous)
}
>
{showAllValues
? 'Show fewer values'
: 'Show all values'}
</Button>
</div>
</Collapsible.Content>
</Collapsible>
<Collapsible defaultOpen>
<Collapsible.Trigger>
<span className="text-base font-semibold">Activity timeline</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div className="p-4">
<ActivityTimeline />
</div>
</Collapsible.Content>
</Collapsible>
</Tabs.TabContent>
<Tabs.TabContent value="notes">
<div className="p-4">
<div className="divide-y">
{notes.map((note) => (
<article
key={note.id}
className="flex gap-3 py-4 first:pt-0 last:pb-0"
>
<Avatar
src={`${assetBase}/avatars/${note.avatar}`}
alt=""
aria-hidden="true"
size={24}
icon={<PiUser />}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="font-medium">
{note.author}
</span>
<time className="text-xs text-muted-foreground">
{note.timestamp}
</time>
</div>
<p className="mt-2 text-sm leading-relaxed text-foreground">
{note.body}
</p>
</div>
</article>
))}
</div>
</div>
</Tabs.TabContent>
</Scroll.FlexSize>
</Tabs>
</Drawer>
</main>
)
}
Slide-out 06
Preview
npx nateui@latest add SlideoutNotificationsDark
import { useState } from 'react'
import Notification from '@/components/patterns/Notification'
import type {
NotificationCategory,
NotificationItem,
} from '@/components/patterns/Notification'
const assetBase = 'https://statics.nateui.com/img'
const notificationItems: NotificationItem[] = [
{
id: 'notif_017',
type: 'MEETING_INVITATION',
preset: 'invitation',
category: 'general',
actor: {
id: 'u5',
name: 'Elena Cruz',
avatar: `${assetBase}/avatars/thumb-5.jpg`,
},
entity: {
type: 'meeting',
id: 'meeting_inventory_sync',
name: 'Inventory sync',
},
payload: {
name: 'Inventory sync',
startTime: '2026-08-19T14:00:00Z',
endTime: '2026-08-19T15:00:00Z',
},
read: false,
createdAt: '2026-08-17T03:44:00Z',
},
{
id: 'notif_001',
type: 'RETURN_ASSIGNED',
preset: 'assignment',
category: 'general',
actor: {
id: 'u5',
name: 'Elena Cruz',
avatar: `${assetBase}/avatars/thumb-5.jpg`,
},
entity: { type: 'return', id: 'RT-3021', name: 'Return #RT-3021' },
payload: { priority: 'medium', dueDate: '2026-08-18T12:00:00Z' },
read: false,
createdAt: '2026-08-17T03:18:00Z',
},
{
id: 'notif_018',
type: 'WORKSPACE_ACCESS_REQUEST',
preset: 'accessRequest',
category: 'general',
actor: {
id: 'u4',
name: 'Zander Lee',
avatar: `${assetBase}/avatars/thumb-4.jpg`,
},
entity: {
type: 'workspace',
id: 'workspace_operations',
name: 'Operations workspace',
},
payload: { requestType: 'workspace' },
read: false,
createdAt: '2026-08-17T02:52:00Z',
},
{
id: 'notif_002',
type: 'PO_APPROVED',
preset: 'approval',
category: 'general',
actor: {
id: 'u9',
name: 'Sophie Turner',
avatar: `${assetBase}/avatars/thumb-10.jpg`,
},
entity: {
type: 'purchaseOrder',
id: 'PO-2079',
name: 'Purchase Order #PO-2079',
},
payload: {},
read: false,
createdAt: '2026-08-17T01:30:00Z',
},
{
id: 'notif_003',
type: 'PRODUCT_COMMENT',
preset: 'comment',
category: 'general',
actor: {
id: 'u4',
name: 'Zander Lee',
avatar: `${assetBase}/avatars/thumb-4.jpg`,
},
entity: {
type: 'product',
id: '1',
name: 'Macbook Pro M4 14"',
image: `${assetBase}/thumbs/products/product-1.jpg`,
},
payload: { comment: 'Updated the wholesale price — please review.' },
read: false,
createdAt: '2026-08-16T23:45:00Z',
},
{
id: 'notif_004',
type: 'ORDER_SHIPPED',
preset: 'update',
category: 'general',
actor: null,
entity: { type: 'order', id: 'ORD-95812', name: 'Order #95812' },
payload: { status: 'shipped' },
read: false,
createdAt: '2026-08-16T16:40:00Z',
},
{
id: 'notif_005',
type: 'REPORT_SHARED',
preset: 'fileShare',
category: 'general',
actor: {
id: 'u11',
name: 'Liam Chen',
avatar: `${assetBase}/avatars/thumb-9.jpg`,
},
entity: { type: 'report', id: 'rep_q2_inv', name: 'Q2 Inventory Summary' },
payload: {
attachments: [
{
id: 'att-1',
name: 'Q2_Inventory_Summary.xlsx',
size: '1.8 MB',
fileType: 'xlsx',
downloadUrl: '/files/q2-inventory-summary.xlsx',
},
],
},
read: false,
createdAt: '2026-08-16T09:20:00Z',
},
{
id: 'notif_006',
type: 'LOW_STOCK',
preset: 'alert',
category: 'general',
actor: null,
entity: {
type: 'product',
id: '7',
name: 'Pulse Analog Watch',
image: `${assetBase}/thumbs/products/product-7.jpg`,
},
payload: { message: 'stock dropped below the reorder threshold.' },
read: false,
createdAt: '2026-08-15T08:00:00Z',
},
{
id: 'notif_007',
type: 'INVENTORY_DUE',
preset: 'reminder',
category: 'general',
actor: null,
entity: { type: 'inventory', id: 'INV-CYCLE-07', name: 'Monthly Stocktake' },
payload: {
message: 'Monthly stocktake is due tomorrow.',
deadline: '2026-08-14T17:00:00Z',
},
read: true,
createdAt: '2026-08-13T15:40:00Z',
},
{
id: 'notif_008',
type: 'FEATURE_RELEASE',
preset: 'announcement',
category: 'general',
actor: null,
entity: { type: 'release', id: 'rel_2_4', name: 'NateUI 2.4' },
payload: {
message:
'Bulk product imports just landed — speed up catalogue updates today.',
},
read: true,
createdAt: '2026-08-11T10:05:00Z',
},
{
id: 'notif_009',
type: 'COMMENT_MENTION',
preset: 'mention',
category: 'mentions',
actor: {
id: 'u2',
name: 'Jeremy Mills',
avatar: `${assetBase}/avatars/thumb-2.jpg`,
},
entity: { type: 'order', id: 'ORD-95954', name: 'Order #95954' },
payload: {
comment:
'Hey @you, can you confirm the shipping address before we dispatch this?',
},
read: false,
createdAt: '2026-08-17T02:15:00Z',
},
{
id: 'notif_010',
type: 'COMMENT_MENTION',
preset: 'mention',
category: 'mentions',
actor: {
id: 'u9',
name: 'Sophie Turner',
avatar: `${assetBase}/avatars/thumb-10.jpg`,
},
entity: {
type: 'product',
id: '15',
name: 'Wireless Earbuds Pro',
image: `${assetBase}/thumbs/products/product-15.jpg`,
},
payload: { comment: '@you the new product photos are ready for upload.' },
read: false,
createdAt: '2026-08-16T18:50:00Z',
},
{
id: 'notif_011',
type: 'COMMENT_MENTION',
preset: 'mention',
category: 'mentions',
actor: {
id: 'u22',
name: 'Brittany Garcia',
avatar: `${assetBase}/avatars/thumb-22.jpg`,
},
entity: { type: 'campaign', id: 'camp_bf', name: 'Black Friday Prep' },
payload: { comment: '@you can you draft the email banner copy this week?' },
read: false,
createdAt: '2026-08-16T11:30:00Z',
},
{
id: 'notif_012',
type: 'COMMENT_MENTION',
preset: 'mention',
category: 'mentions',
actor: {
id: 'u5',
name: 'Elena Cruz',
avatar: `${assetBase}/avatars/thumb-5.jpg`,
},
entity: { type: 'return', id: 'RT-2987', name: 'Return #RT-2987' },
payload: {
comment:
'Looping you in @you — customer is asking for a partial refund.',
},
read: true,
createdAt: '2026-08-15T08:20:00Z',
},
{
id: 'notif_013',
type: 'COMMENT_MENTION',
preset: 'mention',
category: 'mentions',
actor: {
id: 'u3',
name: 'Max Alexander',
avatar: `${assetBase}/avatars/thumb-3.jpg`,
},
entity: { type: 'customer', id: 'CUST-0921', name: 'Olivia Hart' },
payload: {
comment:
'@you flagged as VIP after her last three orders — worth a personal note.',
},
read: true,
createdAt: '2026-08-13T14:10:00Z',
},
{
id: 'notif_014',
type: 'PURCHASE_ORDER_APPROVED',
preset: 'statusChange',
category: 'archive',
actor: null,
entity: {
type: 'purchaseOrder',
id: 'PO-2048',
name: 'Purchase Order #PO-2048',
},
payload: { status: 'approved' },
read: true,
createdAt: '2026-07-27T11:00:00Z',
},
{
id: 'notif_015',
type: 'STOCKTAKE_COMPLETE',
preset: 'system',
category: 'archive',
actor: null,
entity: { type: 'inventory', id: 'INV-CYCLE-06', name: 'Monthly Stocktake' },
payload: { message: 'Main Warehouse stocktake completed for June.' },
read: true,
createdAt: '2026-07-23T17:30:00Z',
},
{
id: 'notif_016',
type: 'RETURN_REVIEW_ASSIGNED',
preset: 'assignment',
category: 'archive',
actor: {
id: 'u5',
name: 'Elena Cruz',
avatar: `${assetBase}/avatars/thumb-5.jpg`,
},
entity: { type: 'return', id: 'RT-2876', name: 'Return #RT-2876' },
payload: { priority: 'low' },
read: true,
createdAt: '2026-07-18T09:00:00Z',
},
]
export default function SlideoutNotifications() {
const [open, setOpen] = useState(true)
const [activeCategory, setActiveCategory] =
useState<NotificationCategory>('general')
const [notifications, setNotifications] = useState(notificationItems)
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Notification
open={open}
activeCategory={activeCategory}
notifications={notifications}
unreadCount={notifications.filter((item) => !item.read).length}
onOpenChange={setOpen}
onCategoryChange={setActiveCategory}
onMarkAllAsRead={() =>
setNotifications((items) =>
items.map((item) => ({ ...item, read: true })),
)
}
showViewAllActivity={false}
onAccessRequestAction={(_, notification) =>
setNotifications((items) =>
items.filter((item) => item.id !== notification.id),
)
}
/>
</main>
)
}
Slide-out 08
Preview
npx nateui@latest add SlideoutTaskDetailsDark
import {
Fragment,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import type { HTMLAttributes, ReactNode, Ref } from 'react'
import dayjs from 'dayjs'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Calendar from '@/components/ui/Calendar'
import Card from '@/components/ui/Card'
import Checkbox from '@/components/ui/Checkbox'
import Drawer from '@/components/ui/Drawer'
import Popover from '@/components/ui/Popover'
import Scroll from '@/components/ui/Scroll'
import Tabs from '@/components/ui/Tabs'
import Tag from '@/components/ui/Tag'
import CloseButton from '@/components/ui/CloseButton'
import Divider from '@/components/composites/Divider'
import EmptyState from '@/components/composites/EmptyState'
import FileIcon from '@/components/composites/FileIcon'
import RichTextEditor from '@/components/composites/RichTextEditor'
import UsersAvatarGroup from '@/components/composites/UsersAvatarGroup'
import classNames from '@/utils/classNames'
import sleep from '@/utils/sleep'
import {
PiArrowsDownUp,
PiBookmarkSimple,
PiCalendarBlank,
PiChatCircleSlash,
PiCheck,
PiCheckSquare,
PiClock,
PiDownloadSimple,
PiFileText,
PiPaperPlaneTilt,
PiTag,
PiUser,
PiX,
} from 'react-icons/pi'
const assetBase = 'https://statics.nateui.com/img'
const slideoutTaskDetailsTitleId = 'slideout-task-details-title'
type Priority = 'urgent' | 'high' | 'normal' | 'low'
type Member = {
id: string
name: string
img: string
}
type Comment = {
id: string
author: Member
createdAt: string
message: string
}
type Subtask = {
id: string
title: string
assigneeIds: string[]
priority: Priority
dueDate: string
completed: boolean
}
type Attachment = {
id: string
name: string
type: string
size: string
}
const members: Member[] = [
{
id: 'mara',
name: 'Mara Chen',
img: `${assetBase}/avatars/thumb-1.jpg`,
},
{
id: 'jonah',
name: 'Jonah Ellis',
img: `${assetBase}/avatars/thumb-7.jpg`,
},
{
id: 'nina',
name: 'Nina Patel',
img: `${assetBase}/avatars/thumb-12.jpg`,
},
]
const priorities: Array<{
value: Priority
label: string
dotClass: string
}> = [
{ value: 'urgent', label: 'Urgent', dotClass: 'bg-destructive' },
{ value: 'high', label: 'High', dotClass: 'bg-warning' },
{ value: 'normal', label: 'Normal', dotClass: 'bg-info' },
{ value: 'low', label: 'Low', dotClass: 'bg-muted-foreground' },
]
const tagOptions = [
{ id: 'launch', label: 'Launch', dotClass: 'bg-primary' },
{ id: 'research', label: 'Research', dotClass: 'bg-info' },
{ id: 'design', label: 'Design', dotClass: 'bg-warning' },
{ id: 'blocked', label: 'Blocked', dotClass: 'bg-destructive' },
{ id: 'venue', label: 'Venue', dotClass: 'bg-success' },
{ id: 'follow-up', label: 'Follow-up', dotClass: 'bg-muted-foreground' },
]
const initialComments: Comment[] = [
{
id: 'comment-1',
author: members[1],
createdAt: '2026-08-12T09:24:00.000Z',
message:
'The event outline is ready for review. I added the open questions to the checklist.',
},
{
id: 'comment-2',
author: members[2],
createdAt: '2026-08-12T13:40:00.000Z',
message:
'I can take the follow-up with the venue once the final guest count is confirmed.',
},
]
const initialSubtasks: Subtask[] = [
{
id: 'subtask-1',
title: 'Confirm speaker availability',
assigneeIds: ['jonah'],
priority: 'high',
dueDate: '2026-08-18T00:00:00.000Z',
completed: true,
},
{
id: 'subtask-2',
title: 'Share the event outline',
assigneeIds: ['nina'],
priority: 'normal',
dueDate: '2026-08-20T00:00:00.000Z',
completed: false,
},
{
id: 'subtask-3',
title: 'Publish the attendee reminder',
assigneeIds: ['mara'],
priority: 'low',
dueDate: '2026-08-22T00:00:00.000Z',
completed: false,
},
]
const initialAttachments: Attachment[] = [
{ id: 'attachment-1', name: 'event-outline.pdf', type: 'pdf', size: '1.8 MB' },
{ id: 'attachment-2', name: 'speaker-notes.docx', type: 'docx', size: '640 KB' },
{ id: 'attachment-3', name: 'venue-layout.png', type: 'png', size: '2.4 MB' },
]
const getMember = (id: string) =>
members.find((member) => member.id === id) ?? members[0]
type FieldOptions = {
label: string
icon: ReactNode
children?: ReactNode
}
const Field = ({ label, icon, children }: FieldOptions) => {
return (
<div className="flex items-start gap-2">
<span className="flex min-h-10 w-36 shrink-0 items-center gap-1.5 font-medium">
<span className="text-base">{icon}</span>
<span>{label}:</span>
</span>
<div className="flex min-w-0 flex-1 items-center">{children}</div>
</div>
)
}
const SelectorWraper = ({
children,
editable = true,
wrap = false,
className,
...rest
}: {
children?: ReactNode
editable?: boolean
wrap?: boolean
className?: string
ref?: Ref<HTMLSpanElement>
} & HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={classNames(
'inline-flex min-h-10 min-w-0 w-full items-center gap-2 rounded-control px-2 py-1',
wrap ? 'flex-wrap' : 'flex-nowrap',
editable &&
'hover:bg-accent focus-within:bg-accent cursor-pointer',
className,
)}
{...rest}
>
{children}
</span>
)
}
type PrioritySelectorOptions = {
children?: ReactNode
value?: string
list: Array<{ value: string; label: string; indicator: string | ReactNode }>
onValueChange: (value: string) => void
}
const PrioritySelector = ({
children,
list,
value,
onValueChange,
}: PrioritySelectorOptions) => {
const [popoverOpen, setPopoverOpen] = useState(false)
return (
<Popover
renderTrigger={children}
open={popoverOpen}
placement="bottom-start"
onOpenChange={setPopoverOpen}
className="p-1"
width={220}
>
<ul>
{list.map((item) => (
<li
className={classNames(
'flex h-9 w-full cursor-pointer items-center justify-between gap-x-2 rounded-control-sm px-3 font-medium text-foreground transition-colors duration-150 hover:bg-accent',
item.value === value && 'font-semibold',
)}
key={item.value}
onClick={() => {
onValueChange(item.value)
setPopoverOpen(false)
}}
role="option"
aria-selected={item.value === value}
tabIndex={-1}
>
<span className="flex items-center gap-2">
{item.indicator}
<span>{item.label}</span>
</span>
{item.value === value && (
<PiCheck className="text-primary text-lg" />
)}
</li>
))}
</ul>
</Popover>
)
}
type DuedateSelectorOptions = {
children?: ReactNode
value?: string
onValueChange: (value: string) => void
}
const DuedateSelector = ({
children,
value,
onValueChange,
}: DuedateSelectorOptions) => {
const [datePickerOpen, setDatePickerOpen] = useState(false)
const handleValueChange = (date: Date) => {
onValueChange(dayjs(date).toISOString())
setDatePickerOpen(false)
}
return (
<Popover
renderTrigger={children}
open={datePickerOpen}
placement="bottom-start"
onOpenChange={setDatePickerOpen}
style={{ width: 280 }}
>
<Calendar
value={dayjs(value).toDate()}
onChange={handleValueChange}
/>
</Popover>
)
}
type AssigneeSelectorOptions = {
children?: ReactNode
value?: string[]
list: Array<{ value: string; label: string; indicator: string | ReactNode }>
onValueChange: (value: string) => void
}
const AssigneeSelector = ({
children,
list,
value,
onValueChange,
}: AssigneeSelectorOptions) => {
const [popoverOpen, setPopoverOpen] = useState(false)
return (
<Popover
renderTrigger={children}
open={popoverOpen}
placement="bottom-start"
onOpenChange={setPopoverOpen}
className="p-1"
style={{ width: 230 }}
>
<Scroll className="h-44">
<ul className="ltr:pr-1.5 rtl:pl-1.5">
{list.map((item) => (
<li
className={classNames(
'flex h-9 w-full cursor-pointer items-center justify-between gap-x-2 rounded-control-sm px-3 font-medium text-foreground transition-colors duration-150 hover:bg-accent',
value?.includes(item.value) && 'font-semibold',
)}
key={item.value}
onClick={() => {
onValueChange(item.value)
}}
role="option"
aria-selected={value?.includes(item.value)}
tabIndex={-1}
>
<span className="flex items-center gap-2">
{item.indicator}
<span>{item.label}</span>
</span>
{value?.includes(item.value) && (
<PiCheck className="text-primary text-lg" />
)}
</li>
))}
</ul>
</Scroll>
</Popover>
)
}
type TagsSelectorOptions = {
children?: ReactNode
value?: string[]
list: Array<{ value: string; label: string }>
onValueChange: (value: string) => void
}
const TagsSelector = ({
children,
list,
value,
onValueChange,
}: TagsSelectorOptions) => {
const [popoverOpen, setPopoverOpen] = useState(false)
return (
<Popover
renderTrigger={children}
open={popoverOpen}
placement="bottom-start"
onOpenChange={setPopoverOpen}
className="p-1"
width={220}
>
<Scroll className="h-44">
<ul className="ltr:pr-1.5 rtl:pl-1.5">
{list.map((item) => (
<li
className={classNames(
'flex h-9 w-full cursor-pointer items-center justify-between gap-x-2 rounded-control-sm px-3 font-medium text-foreground transition-colors duration-150 hover:bg-accent',
value?.includes(item.value) && 'font-semibold',
)}
key={item.value}
onClick={() => {
onValueChange(item.value)
}}
role="option"
aria-selected={value?.includes(item.value)}
tabIndex={-1}
>
<span className="flex items-center gap-2">
<span>{item.label}</span>
</span>
{value?.includes(item.value) && (
<PiCheck className="text-primary text-lg" />
)}
</li>
))}
</ul>
</Scroll>
</Popover>
)
}
const SubjectEditor = ({
className,
value,
onValueChange,
}: {
className?: string
value: string
onValueChange: (value: string) => void
}) => {
const [editing, setEditing] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const handleEdit = async () => {
setEditing(true)
await sleep(10)
inputRef.current?.focus()
}
const handleChange = () => {
if (inputRef.current) {
onValueChange(inputRef.current.value)
setEditing(false)
inputRef.current.blur()
}
}
const handleClose = () => {
setEditing(false)
inputRef.current?.blur()
}
return (
<>
{editing ? (
<span
className={classNames(
'flex items-center justify-between',
className,
)}
>
<input
ref={inputRef}
defaultValue={value}
aria-label="Task subject"
className="min-h-6 h-full w-full outline-0 leading-normal"
onBlur={handleClose}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation()
handleChange()
}
}}
onClick={(e) => {
e.stopPropagation()
inputRef.current?.focus()
}}
/>
<span className="flex gap-2">
<button type="button" onMouseDown={handleChange} aria-label="Save subject">
<PiCheck className="hover:text-success" />
</button>
<button
type="button"
onMouseDown={handleClose}
className="hover:text-destructive"
aria-label="Cancel subject edit"
>
<PiX />
</button>
</span>
</span>
) : (
<span
onClick={handleEdit}
className={classNames('cursor-pointer', className)}
>
{value}
</span>
)}
</>
)
}
type CommentInputRef = {
handleFocus: () => void
}
type CommentInputOptions = {
onSubmit: ({ message }: { message: string }) => void
onCancel?: () => void
onChange?: (value: string) => void
className?: string
ref?: Ref<CommentInputRef>
extraTools?: ReactNode
}
const CommentInput = ({
onSubmit,
onChange,
onCancel,
className,
extraTools,
ref,
}: CommentInputOptions) => {
const editor = useEditor({
extensions: [
StarterKit.configure({
bulletList: {
keepMarks: true,
},
orderedList: {
keepMarks: true,
},
}),
],
editorProps: {
attributes: {
class: 'focus:outline-hidden min-h-20 p-3 prose prose-p:text-sm',
},
},
immediatelyRender: false,
onUpdate: ({ editor: nextEditor }) => {
const textContent = nextEditor.getText()
onChange?.(textContent || '')
},
})
const handleSubmit = () => {
if (editor) {
onSubmit({ message: editor.getText() })
editor.commands.clearContent()
}
}
const handleFocus = useCallback(() => {
editor?.commands.focus()
}, [editor])
useImperativeHandle(ref, () => {
return {
handleFocus,
}
}, [handleFocus])
return (
<div
className={classNames(
'flex min-h-30 flex-col rounded-control border border-border shadow-card',
className,
)}
>
<div>
<div>
<EditorContent className="h-full" editor={editor} />
</div>
<div className="flex items-center justify-between px-4 py-2">
<div className="flex justify-center gap-x-1 gap-y-2">
{editor && (
<>
<RichTextEditor.ToolButtonBold editor={editor} />
<RichTextEditor.ToolButtonItalic editor={editor} />
<RichTextEditor.ToolButtonStrike editor={editor} />
<RichTextEditor.ToolButtonBulletList editor={editor} />
<RichTextEditor.ToolButtonOrderedList editor={editor} />
<RichTextEditor.ToolButtonHorizontalRule editor={editor} />
{extraTools}
</>
)}
</div>
<div className="flex items-center gap-2">
{onCancel && <Button onClick={onCancel}>Cancel</Button>}
<Button
size="sm"
variant="solid"
icon={<PiPaperPlaneTilt className="text-xl" />}
onClick={handleSubmit}
aria-label="Submit comment"
/>
</div>
</div>
</div>
</div>
)
}
export default function SlideoutTaskDetails() {
const [isOpen, setIsOpen] = useState(true)
const [subject, setSubject] = useState('Prepare autumn customer event')
const [priority, setPriority] = useState<Priority>('high')
const [dueDate, setDueDate] = useState('2026-08-20T00:00:00.000Z')
const [assigneeIds, setAssigneeIds] = useState<string[]>(['mara'])
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([
'launch',
'design',
])
const [description, setDescription] = useState(
'Coordinate the autumn customer event from the first speaker check-in through the attendee follow-up.',
)
const [isEditingDescription, setIsEditingDescription] = useState(false)
const [editedDescription, setEditedDescription] = useState('')
const [comments, setComments] = useState<Comment[]>(initialComments)
const [subtasks, setSubtasks] = useState<Subtask[]>(initialSubtasks)
const [attachments] = useState<Attachment[]>(initialAttachments)
const [isSaving, setIsSaving] = useState(false)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => {
if (saveTimeoutRef.current !== null) {
clearTimeout(saveTimeoutRef.current)
}
}
}, [])
const priorityOptions = useMemo(
() =>
priorities.map((item) => ({
label: item.label,
value: item.value,
indicator: (
<span
className={classNames(
'h-2.5 w-2.5 rounded-xs',
item.dotClass,
)}
></span>
),
})),
[],
)
const assigneeOptions = useMemo(
() =>
members.map((member) => ({
label: member.name,
value: member.id,
indicator: (
<Avatar src={member.img} size={25} />
),
})),
[],
)
const tagsList = useMemo(
() => tagOptions.map((tag) => ({ value: tag.id, label: tag.label })),
[],
)
const handleCommentSubmit = ({ message }: { message: string }) => {
setComments((currentComments) => [
...currentComments,
{
id: `comment-${currentComments.length + 1}`,
author: members[0],
message,
createdAt: '2026-08-15T10:15:00.000Z',
},
])
}
const handleSave = () => {
if (saveTimeoutRef.current !== null) {
clearTimeout(saveTimeoutRef.current)
}
setIsSaving(true)
saveTimeoutRef.current = setTimeout(() => {
setIsSaving(false)
setIsOpen(false)
saveTimeoutRef.current = null
}, 500)
}
const handleSubTaskCheck = (id: string, checked: boolean) => {
setSubtasks((currentSubtasks) =>
currentSubtasks.map((subtask) =>
subtask.id === id ? { ...subtask, completed: checked } : subtask,
),
)
}
const handleMutate = ({ key, value }: { key: string; value: string }) => {
if (key === 'priority') {
setPriority(value as Priority)
}
if (key === 'dueDate') {
setDueDate(value)
}
if (key === 'assignee') {
setAssigneeIds((currentIds) =>
currentIds.includes(value)
? currentIds.filter((id) => id !== value)
: [...currentIds, value],
)
}
if (key === 'tags') {
setSelectedTagIds((currentTags) =>
currentTags.includes(value)
? currentTags.filter((id) => id !== value)
: [...currentTags, value],
)
}
if (key === 'subject') {
setSubject(value)
}
if (key === 'description') {
setDescription(value)
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={() => setIsOpen(true)}>
Open task details
</Button>
<Drawer
aria-labelledby={slideoutTaskDetailsTitleId}
aria-label="Task details"
title={
<h4 id={slideoutTaskDetailsTitleId} className="w-full">
<SelectorWraper className="max-w-full">
<SubjectEditor
className="flex-1"
value={subject}
onValueChange={(value) =>
handleMutate({ key: 'subject', value })
}
/>
</SelectorWraper>
</h4>
}
headerClass="pe-12"
bodyClass="flex min-h-0 flex-1 flex-col overflow-hidden p-0"
closable
contentClassName="max-w-[calc(100vw-1rem)]"
footer={
<>
<Button onClick={() => setIsOpen(false)}>Cancel</Button>
<Button variant="solid" loading={isSaving} onClick={handleSave}>
Save
</Button>
</>
}
footerClass="justify-end gap-2"
isOpen={isOpen}
onClose={() => setIsOpen(false)}
placement="right"
shouldCloseOnEsc
shouldCloseOnOverlayClick
width={520}
>
<Scroll
className="min-h-0 flex-1"
scrollbars="vertical"
type="auto"
>
<div className="space-y-4 py-4">
<div className="px-4">
<Field label="Ticket" icon={<PiBookmarkSimple />}>
<SelectorWraper editable={false}>OPS-482</SelectorWraper>
</Field>
<Field label="Priority" icon={<PiArrowsDownUp />}>
<PrioritySelector
value={priority}
list={priorityOptions}
onValueChange={(value) =>
handleMutate({ key: 'priority', value })
}
>
<SelectorWraper>
<Tag
className="inline-flex items-center gap-1 bg-transparent px-1.5 py-0.5"
prefix={
<span
className={classNames(
'h-2.5 w-2.5 rounded-xs',
priorities.find(
(item) => item.value === priority,
)?.dotClass,
)}
></span>
}
>
{priority}
</Tag>
</SelectorWraper>
</PrioritySelector>
</Field>
<Field label="Due date" icon={<PiClock />}>
<DuedateSelector
value={dueDate}
onValueChange={(value) =>
handleMutate({ key: 'dueDate', value })
}
>
<SelectorWraper>
{dayjs(dueDate).format('DD MMM YYYY')}
</SelectorWraper>
</DuedateSelector>
</Field>
<Field label="Assigned to" icon={<PiUser />}>
<AssigneeSelector
list={assigneeOptions}
value={assigneeIds}
onValueChange={(value) =>
handleMutate({ key: 'assignee', value })
}
>
<SelectorWraper>
{assigneeIds.length > 1 ? (
<UsersAvatarGroup
users={assigneeIds.map(getMember)}
avatarProps={{ size: 25 }}
/>
) : (
<>
{assigneeIds.map((id) => {
const member = getMember(id)
return (
<div
key={member.id}
className="flex items-center gap-1"
>
<Avatar
size={22}
src={member.img}
alt={member.name}
/>
<div>{member.name}</div>
</div>
)
})}
</>
)}
</SelectorWraper>
</AssigneeSelector>
</Field>
<Field label="Tags" icon={<PiTag />}>
<TagsSelector
list={tagsList}
value={selectedTagIds}
onValueChange={(value) =>
handleMutate({ key: 'tags', value })
}
>
<SelectorWraper wrap>
{selectedTagIds.map((tagId) => (
<Tag
className="px-1.5 py-0.5"
key={tagId}
>
{tagOptions.find(
(tag) => tag.id === tagId,
)?.label ?? tagId}
</Tag>
))}
</SelectorWraper>
</TagsSelector>
</Field>
</div>
<div className="px-4">
{isEditingDescription ? (
<>
<h6 className="mb-1">Description</h6>
<div onClick={(event) => event.stopPropagation()}>
<RichTextEditor
content={editedDescription}
onChange={({ html }) =>
setEditedDescription(html)
}
/>
<div className="mt-3 flex justify-end gap-2">
<Button
size="sm"
onClick={() => {
setIsEditingDescription(false)
setEditedDescription('')
}}
>
Cancel
</Button>
<Button
size="sm"
variant="solid"
onClick={() => {
handleMutate({
key: 'description',
value: editedDescription,
})
setIsEditingDescription(false)
}}
>
Save
</Button>
</div>
</div>
</>
) : (
<Card
className={classNames(
'bg-accent',
!isEditingDescription &&
'cursor-pointer transition-all hover:ring-1 hover:ring-border',
)}
onClick={() => {
if (!isEditingDescription) {
setEditedDescription(description)
setIsEditingDescription(true)
}
}}
>
<h6 className="mb-1">Description</h6>
<p className={classNames(!description && 'italic')}>
{description || 'Click to add description...'}
</p>
</Card>
)}
</div>
<div>
<Tabs defaultValue="comments">
<Tabs.TabList className="px-4">
<Tabs.TabNav value="comments">Comments</Tabs.TabNav>
<Tabs.TabNav value="subtasks">Subtasks</Tabs.TabNav>
<Tabs.TabNav value="attachments">Attachments</Tabs.TabNav>
</Tabs.TabList>
<div className="px-6 py-4">
<Tabs.TabContent value="comments">
<div>
{comments.map((comment, index) => (
<Fragment key={comment.id}>
<div className="flex gap-2 py-4">
<div>
<Avatar
src={comment.author.img}
size={30}
/>
</div>
<div className="rounded-sm">
<div className="mb-1 flex items-center">
<span className="font-medium">
{comment.author.name}
</span>
<span className="mx-1"> • </span>
<span className="text-xs">
{dayjs(
comment.createdAt,
).format('hh:mm A')}
</span>
</div>
<div className="mb-0 prose text-sm prose-p:text-sm prose-p:leading-normal [--tw-prose-body:var(--nui-muted-foreground)]">
{comment.message}
</div>
</div>
</div>
{index !== comments.length - 1 && <Divider />}
</Fragment>
))}
{comments.length === 0 && (
<div className="flex flex-1 flex-col items-center justify-center">
<EmptyState
variant="dots"
size={180}
offset={-20}
illustration={
<Avatar shape="round"
className="bg-card ring-1 ring-border"
icon={<PiChatCircleSlash className="text-xl" />}
/>
}
>
<div className="text-center">
<h5>No comment yet</h5>
<p className="max-w-full">
Be the first one to comment
</p>
</div>
</EmptyState>
</div>
)}
<div className="mt-8">
<CommentInput
onSubmit={handleCommentSubmit}
/>
</div>
</div>
</Tabs.TabContent>
<Tabs.TabContent value="subtasks">
{subtasks.length === 0 && (
<div className="flex flex-1 flex-col items-center justify-center">
<EmptyState
variant="dots"
size={180}
offset={-20}
illustration={
<Avatar shape="round"
className="bg-card ring-1 ring-border"
icon={<PiCheckSquare className="text-xl" />}
/>
}
>
<div className="text-center">
<h5>No subtasks</h5>
</div>
</EmptyState>
</div>
)}
<div>
{subtasks.map((subtask, index) => (
<Fragment key={subtask.id}>
<div
className="flex cursor-pointer items-center justify-between px-2 py-2"
tabIndex={0}
role="button"
onClick={() =>
handleSubTaskCheck(
subtask.id,
!subtask.completed,
)
}
>
<div className="flex items-center gap-2">
<Checkbox checked={subtask.completed} />
<div
className={classNames(
'font-medium leading-none',
subtask.completed && 'line-through',
)}
>
{subtask.title}
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center">
<UsersAvatarGroup
avatarProps={{ size: 22 }}
users={subtask.assigneeIds.map(getMember)}
/>
</div>
<div className="flex min-w-16 items-center gap-1 font-medium">
<PiCalendarBlank className="text-lg" />
<span className="text-xs">
{dayjs(subtask.dueDate).format('MMM DD')}
</span>
</div>
</div>
</div>
{index !== subtasks.length - 1 && <Divider />}
</Fragment>
))}
</div>
</Tabs.TabContent>
<Tabs.TabContent value="attachments">
{attachments.length === 0 ? (
<div className="mb-4 flex flex-1 flex-col items-center justify-center">
<EmptyState
variant="dots"
size={180}
offset={-20}
illustration={
<Avatar shape="round"
className="bg-card ring-1 ring-border"
icon={<PiFileText className="text-xl" />}
/>
}
>
<div className="text-center">
<h5>No attachments</h5>
</div>
</EmptyState>
</div>
) : (
<Card bodyClass="divide-y divide-border p-0">
{attachments.map((attachment) => (
<div
key={attachment.id}
className="flex items-center justify-between p-2"
>
<div className="flex items-center gap-2">
<FileIcon
type={attachment.type}
size={25}
/>
<div className="font-medium">
{attachment.name}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
icon={<PiDownloadSimple />}
aria-label={`Download ${attachment.name}`}
/>
</div>
</div>
))}
</Card>
)}
</Tabs.TabContent>
</div>
</Tabs>
</div>
</div>
</Scroll>
</Drawer>
</main>
)
}
Slide-out 09
Preview
npx nateui@latest add SlideoutCreateEventDark
import { useState } from 'react'
import Button from '@/components/ui/Button'
import Checkbox from '@/components/ui/Checkbox'
import CloseButton from '@/components/ui/CloseButton'
import DatePicker from '@/components/ui/DatePicker'
import Drawer from '@/components/ui/Drawer'
import { Form } from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Scroll from '@/components/ui/Scroll'
import IconFrame from '@/components/composites/IconFrame'
import {
PiCalendarPlus,
PiMapPin,
} from 'react-icons/pi'
const initialEventTitle = 'Team planning session'
const initialStartDate = new Date(2026, 8, 10, 9, 30)
const initialEndDate = new Date(2026, 8, 10, 11, 0)
const initialReminders = ['one-hour', 'one-day']
const startDateLabelId = 'slideout-create-event-start-date-label'
const startDateInputId = 'slideout-create-event-start-date'
const endDateLabelId = 'slideout-create-event-end-date-label'
const endDateInputId = 'slideout-create-event-end-date'
export default function SlideoutCreateEvent() {
const [isOpen, setIsOpen] = useState(true)
const [eventTitle, setEventTitle] = useState(initialEventTitle)
const [startDate, setStartDate] = useState<Date | null>(initialStartDate)
const [endDate, setEndDate] = useState<Date | null>(initialEndDate)
const [location, setLocation] = useState('')
const [description, setDescription] = useState('')
const [reminders, setReminders] = useState(initialReminders)
const closeDrawer = () => setIsOpen(false)
const setStartDateInputRef = (input: HTMLInputElement | null) => {
if (input) {
input.id = startDateInputId
input.setAttribute('aria-labelledby', startDateLabelId)
}
}
const setEndDateInputRef = (input: HTMLInputElement | null) => {
if (input) {
input.id = endDateInputId
input.setAttribute('aria-labelledby', endDateLabelId)
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={() => setIsOpen(true)}>
Open create event
</Button>
<Drawer
aria-label="Create event"
isOpen={isOpen}
placement="right"
width={480}
closable={false}
bodyClass="flex min-h-0 flex-1 flex-col overflow-hidden p-0"
contentClassName="max-w-[calc(100vw-1rem)]"
footer={
<>
<Button
type="button"
variant="default"
onClick={closeDrawer}
>
Cancel
</Button>
<Button
type="button"
variant="solid"
onClick={closeDrawer}
>
Create
</Button>
</>
}
footerClass="justify-end gap-2"
shouldCloseOnEsc
shouldCloseOnOverlayClick
onClose={closeDrawer}
>
<header className="flex shrink-0 items-start justify-between gap-4 border-b p-4">
<div className="flex min-w-0 items-start gap-3">
<IconFrame
variant="layered"
size={40}
className="shrink-0"
>
<PiCalendarPlus
aria-hidden="true"
className="text-xl"
/>
</IconFrame>
<div className="min-w-0">
<h5>Create event</h5>
<p className="text-muted-foreground">
Plan the essentials before the event begins.
</p>
</div>
</div>
<CloseButton
type="button"
aria-label="Close create event drawer"
onClick={closeDrawer}
/>
</header>
<Scroll
className="min-h-0 flex-1"
contentClassName="block min-w-0 w-full"
scrollbars="vertical"
type="auto"
>
<div className="p-4">
<Form
aria-label="Create event form"
containerClassName="flex flex-col gap-4"
onSubmit={(event) => event.preventDefault()}
>
<Form.Field
label="Title"
asterisk
htmlFor="slideout-create-event-title"
className="mb-0"
>
<Input
id="slideout-create-event-title"
value={eventTitle}
onChange={(event) =>
setEventTitle(event.target.value)
}
/>
</Form.Field>
<Form.Field
label="Start date"
asterisk
labelId={startDateLabelId}
htmlFor={startDateInputId}
className="mb-0"
>
<DatePicker.DateTime
ref={setStartDateInputRef}
value={startDate}
inputFormat="DD-MMM-YYYY hh:mm a"
onChange={setStartDate}
/>
</Form.Field>
<Form.Field
label="End date"
asterisk
labelId={endDateLabelId}
htmlFor={endDateInputId}
className="mb-0"
>
<DatePicker.DateTime
ref={setEndDateInputRef}
value={endDate}
inputFormat="DD-MMM-YYYY hh:mm a"
onChange={setEndDate}
/>
</Form.Field>
<Form.Field
label="Location"
htmlFor="slideout-create-event-location"
className="mb-0"
>
<Input
id="slideout-create-event-location"
prefix={
<PiMapPin
aria-hidden="true"
className="text-lg"
/>
}
placeholder="Search for a venue"
value={location}
onChange={(event) =>
setLocation(event.target.value)
}
/>
</Form.Field>
<Form.Field
label="Description"
htmlFor="slideout-create-event-description"
className="mb-0"
>
<Input
id="slideout-create-event-description"
textArea
rows={4}
placeholder="Include helpful context"
value={description}
onChange={(event) =>
setDescription(event.target.value)
}
/>
</Form.Field>
</Form>
<section
aria-labelledby="event-reminders-heading"
className="mt-8 space-y-4 border-t pt-4"
>
<h6 id="event-reminders-heading">Reminders</h6>
<Checkbox.Group
aria-labelledby="event-reminders-heading"
name="event-reminders"
vertical
value={reminders}
onChange={setReminders}
className="gap-3"
>
<Checkbox value="ten-minutes">
10 minutes before
</Checkbox>
<Checkbox value="one-hour">
1 hour before
</Checkbox>
<Checkbox value="one-day">1 day before</Checkbox>
</Checkbox.Group>
</section>
</div>
</Scroll>
</Drawer>
</main>
)
}
Slide-out 10
Preview
npx nateui@latest add SlideoutColumnManagerDark
import { useMemo, useState } from 'react'
import {
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import type { CSSProperties, ReactNode, Ref } from 'react'
import Button from '@/components/ui/Button'
import CloseButton from '@/components/ui/CloseButton'
import Drawer from '@/components/ui/Drawer'
import Input from '@/components/ui/Input'
import Scroll from '@/components/ui/Scroll'
import Switcher from '@/components/ui/Switcher'
import classNames from '@/utils/classNames'
import {
PiAt,
PiCalendarBlank,
PiCircle,
PiCreditCard,
PiCurrencyDollar,
PiDotsSixVertical,
PiHash,
PiListNumbers,
PiMagnifyingGlass,
PiMapPin,
PiPhone,
PiUser,
PiX,
PiMapTrifold,
PiBuildings,
PiMoneyWavy
} from 'react-icons/pi'
const columnTypeIcons = {
identifier: <PiHash aria-hidden="true" />,
person: <PiUser aria-hidden="true" />,
date: <PiCalendarBlank aria-hidden="true" />,
state: <PiCircle aria-hidden="true" />,
payment: <PiCreditCard aria-hidden="true" />,
currency: <PiCurrencyDollar aria-hidden="true" />,
number: <PiListNumbers aria-hidden="true" />,
contact: <PiAt aria-hidden="true" />,
phone: <PiPhone aria-hidden="true" />,
place: <PiMapPin aria-hidden="true" />,
map: <PiMapTrifold aria-hidden="true" />,
buildings: <PiBuildings aria-hidden="true" />,
money: <PiMoneyWavy aria-hidden="true" />
} satisfies Record<string, ReactNode>
type ColumnType = keyof typeof columnTypeIcons
type ColumnId =
| 'order-number'
| 'buyer'
| 'placed-on'
| 'fulfillment-state'
| 'tender'
| 'charge-state'
| 'order-total'
| 'line-count'
| 'buyer-contact'
| 'direct-line'
| 'payment-reference'
| 'town'
| 'province'
| 'market'
type ColumnDefinition = {
id: ColumnId
label: string
type: ColumnType
locked?: boolean
}
const columnDefinitions: ColumnDefinition[] = [
{
id: 'order-number',
label: 'Order ID',
type: 'identifier',
locked: true,
},
{
id: 'buyer',
label: 'Customer',
type: 'person',
},
{
id: 'placed-on',
label: 'Order date',
type: 'date',
},
{
id: 'fulfillment-state',
label: 'Status',
type: 'state',
},
{
id: 'tender',
label: 'Payment method',
type: 'payment',
},
{
id: 'charge-state',
label: 'Payment status',
type: 'money',
},
{
id: 'order-total',
label: 'Total amount',
type: 'currency',
},
{
id: 'line-count',
label: 'Product count',
type: 'number',
},
{
id: 'buyer-contact',
label: 'Customer email',
type: 'contact',
},
{
id: 'direct-line',
label: 'Phone number',
type: 'phone',
},
{
id: 'payment-reference',
label: 'Payment identifier',
type: 'identifier',
},
{
id: 'town',
label: 'City',
type: 'buildings',
},
{
id: 'province',
label: 'State',
type: 'place',
},
{
id: 'market',
label: 'Country',
type: 'map',
},
]
const columnById = Object.fromEntries(
columnDefinitions.map((column) => [column.id, column]),
) as Record<ColumnId, ColumnDefinition>
const lockedColumnId: ColumnId = 'order-number'
const initialShownColumnIds: ColumnId[] = [
'order-number',
'buyer',
'placed-on',
'fulfillment-state',
'tender',
'charge-state',
'order-total',
'line-count',
'buyer-contact',
]
const initialHiddenColumnIds: ColumnId[] = [
'direct-line',
'payment-reference',
'town',
'province',
'market',
]
function ColumnRow({
column,
checked,
dragHandle,
locked = false,
onToggle,
rowRef,
style,
}: {
column: ColumnDefinition
checked: boolean
dragHandle: ReactNode
locked?: boolean
onToggle: (checked: boolean) => void
rowRef?: Ref<HTMLLIElement>
style?: CSSProperties
}) {
return (
<li
ref={rowRef}
className="flex min-w-0 items-center gap-2 rounded-control px-1 py-1.5"
style={style}
data-column-id={column.id}
data-column-type={column.type}
>
<span
className="flex h-5 w-5 shrink-0 items-center justify-center"
data-column-role="drag"
>
{dragHandle}
</span>
<span
className={classNames(
'flex text-xl shrink-0 items-center justify-center',
locked
? 'text-control-disabled-foreground'
: 'text-foreground',
)}
data-column-role="type"
>
{columnTypeIcons[column.type]}
</span>
<span
className={classNames(
'min-w-0 flex-1 truncate',
locked && 'text-control-disabled-foreground',
)}
data-column-role="name"
>
{column.label}
</span>
<span className="shrink-0" data-column-role="toggle">
<Switcher
aria-label={`${checked ? 'Hide' : 'Show'} ${column.label}`}
checked={checked}
disabled={locked}
onChange={onToggle}
/>
</span>
</li>
)
}
function InertGrip() {
return (
<span
aria-hidden="true"
className="flex h-5 w-5 items-center justify-center text-control-disabled-foreground"
>
<PiDotsSixVertical aria-hidden="true" />
</span>
)
}
function SortableColumnRow({
column,
isDragDisabled,
onToggle,
}: {
column: ColumnDefinition
isDragDisabled: boolean
onToggle: (checked: boolean) => void
}) {
const canDrag = !column.locked && !isDragDisabled
const {
attributes,
listeners,
setActivatorNodeRef,
setNodeRef,
transform,
transition,
} = useSortable({ id: column.id, disabled: !canDrag })
return (
<ColumnRow
checked
column={column}
locked={column.locked}
rowRef={setNodeRef}
style={{
transform: CSS.Translate.toString(transform),
transition,
}}
dragHandle={
canDrag ? (
<button
ref={setActivatorNodeRef}
type="button"
className="flex h-5 w-5 cursor-grab items-center justify-center rounded-control text-foreground/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary active:cursor-grabbing"
aria-label={`Reorder ${column.label}`}
data-column-drag-handle="true"
{...attributes}
{...listeners}
>
<PiDotsSixVertical aria-hidden="true" />
</button>
) : (
<InertGrip />
)
}
onToggle={onToggle}
/>
)
}
export default function SlideoutColumnManager() {
const [isOpen, setIsOpen] = useState(true)
const [query, setQuery] = useState('')
const [shownColumnIds, setShownColumnIds] = useState(initialShownColumnIds)
const [hiddenColumnIds, setHiddenColumnIds] = useState(initialHiddenColumnIds)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const normalizedQuery = query.trim().toLowerCase()
const matchesQuery = (columnId: ColumnId) =>
!normalizedQuery ||
columnById[columnId].label.toLowerCase().includes(normalizedQuery)
const filteredShownColumnIds = useMemo(
() => shownColumnIds.filter(matchesQuery),
[normalizedQuery, shownColumnIds],
)
const filteredHiddenColumnIds = useMemo(
() => hiddenColumnIds.filter(matchesQuery),
[normalizedQuery, hiddenColumnIds],
)
const handleToggle = (columnId: ColumnId, checked: boolean) => {
if (columnId === lockedColumnId) return
if (checked) {
setHiddenColumnIds((current) =>
current.filter((id) => id !== columnId),
)
setShownColumnIds((current) =>
current.includes(columnId) ? current : [...current, columnId],
)
return
}
setShownColumnIds((current) =>
current.filter((id) => id !== columnId),
)
setHiddenColumnIds((current) =>
current.includes(columnId) ? current : [...current, columnId],
)
}
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id || normalizedQuery) return
const activeId = String(active.id) as ColumnId
const overId = String(over.id) as ColumnId
const oldIndex = shownColumnIds.indexOf(activeId)
const newIndex = shownColumnIds.indexOf(overId)
if (oldIndex === -1 || newIndex === -1) return
setShownColumnIds((current) => arrayMove(current, oldIndex, newIndex))
}
const handleHideAll = () => {
setHiddenColumnIds((current) => [
...current,
...shownColumnIds.filter(
(id) => id !== lockedColumnId && !current.includes(id),
),
])
setShownColumnIds([lockedColumnId])
}
const handleShowAll = () => {
setShownColumnIds((current) => [
...new Set([...current, ...hiddenColumnIds]),
])
setHiddenColumnIds([])
}
const hasSearchMatches =
filteredShownColumnIds.length > 0 ||
filteredHiddenColumnIds.length > 0
return (
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<Button type="button" onClick={() => setIsOpen(true)}>
Open column manager
</Button>
<Drawer
aria-label="Column manager"
bodyClass="flex min-h-0 flex-1 flex-col overflow-hidden p-0"
closable={false}
contentClassName="max-w-[calc(100vw-1rem)]"
isOpen={isOpen}
onClose={() => setIsOpen(false)}
placement="right"
shouldCloseOnEsc
shouldCloseOnOverlayClick
width={400}
>
<header className="shrink-0 px-4 pt-4">
<div className="flex items-center justify-between gap-4">
<h5>Column manager</h5>
<CloseButton
aria-label="Close column manager"
onClick={() => setIsOpen(false)}
/>
</div>
<Input
aria-label="Search columns"
className="mt-4 w-full"
placeholder="Search columns"
prefix={<PiMagnifyingGlass aria-hidden="true" />}
suffix={
query ? (
<Button
type="button"
variant="ghost"
shape="circle"
size="sm"
icon={<PiX aria-hidden="true" />}
aria-label="Clear column search"
onClick={() => setQuery('')}
/>
) : undefined
}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</header>
<Scroll
className="min-h-0 flex-1"
contentClassName="p-4"
scrollbars="vertical"
type="auto"
>
<DndContext
sensors={sensors}
onDragEnd={handleDragEnd}
>
<section
aria-labelledby="slideout-column-manager-shown"
data-column-group="shown"
>
<div className="flex items-center justify-between gap-4">
<div
className="font-medium"
id="slideout-column-manager-shown"
>
Shown
</div>
<Button
type="button"
variant="link"
size="sm"
className="px-0 font-normal text-muted-foreground/80 hover:text-foreground"
disabled={shownColumnIds.every(
(id) => id === lockedColumnId,
)}
onClick={handleHideAll}
>
Hide all
</Button>
</div>
<ul className="mt-2 space-y-0">
<SortableContext
items={shownColumnIds}
strategy={verticalListSortingStrategy}
>
{filteredShownColumnIds.length > 0
? filteredShownColumnIds.map((columnId) => (
<SortableColumnRow
key={columnId}
column={columnById[columnId]}
isDragDisabled={Boolean(normalizedQuery)}
onToggle={(checked) =>
handleToggle(columnId, checked)
}
/>
))
: null}
</SortableContext>
</ul>
</section>
<section
aria-labelledby="slideout-column-manager-hidden"
className="mt-2 border-t pt-2"
data-column-group="hidden"
>
<div className="flex items-center justify-between gap-4">
<div
className="font-medium"
id="slideout-column-manager-hidden"
>
Hidden
</div>
<Button
type="button"
variant="link"
size="sm"
className="px-0 font-normal text-muted-foreground/80 hover:text-foreground"
disabled={hiddenColumnIds.length === 0}
onClick={handleShowAll}
>
Show all
</Button>
</div>
<ul className="mt-2 space-y-0">
{filteredHiddenColumnIds.length > 0
? filteredHiddenColumnIds.map((columnId) => (
<ColumnRow
key={columnId}
checked={false}
column={columnById[columnId]}
dragHandle={<InertGrip />}
onToggle={(checked) =>
handleToggle(columnId, checked)
}
/>
))
: null}
</ul>
</section>
{normalizedQuery && !hasSearchMatches && (
<p className="text-muted-foreground">
No columns match this search.
</p>
)}
</DndContext>
</Scroll>
</Drawer>
</main>
)
}