Slide-outs

9 blocks

Slide-out 01

Preview
npx nateui@latest add SlideoutAiChat
Dark
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 SlideoutShareProject
Dark
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 SlideoutProductFilters
Dark
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 SlideoutCreateProject
Dark
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 &amp; 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 SlideoutClientDetails
Dark
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 SlideoutNotifications
Dark
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 SlideoutTaskDetails
Dark
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 SlideoutCreateEvent
Dark
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 SlideoutColumnManager
Dark
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>
    )
}