Onboarding

5 blocks

Onboarding 01

Preview
npx nateui@latest add OnboardingSetupWizard
Dark
import { useState } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
import Container from '@/components/composites/Container'
import IconFrame from '@/components/composites/IconFrame'
import Logo from '@/components/layouts/Logo'
import classNames from '@/utils/classNames'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
import Checkbox from '@/components/ui/Checkbox'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Progress from '@/components/ui/Progress'
import Radio from '@/components/ui/Radio'
import Select from '@/components/ui/Select'
import Upload from '@/components/ui/Upload'
import {
    PiArrowLeft,
    PiArrowRight,
    PiBell,
    PiCalendarBlank,
    PiChartBar,
    PiChartLineUp,
    PiChatCircleText,
    PiCheck,
    PiCheckBold,
    PiCheckCircleFill,
    PiCheckSquare,
    PiFileText,
    PiKanban,
    PiLightbulb,
    PiMapTrifold,
    PiNote,
    PiRows,
    PiSquaresFour,
    PiUploadSimple,
    PiUser,
    PiUsersThree,
} from 'react-icons/pi'

const assetBase = 'https://statics.nateui.com/img'
const logoSrc = `${assetBase}/logos/logo.svg`
const logoWhiteSrc = `${assetBase}/logos/logo-white.svg`

const totalSteps = 5

const stepDetails = [
    {
        title: 'Add profile details',
        subtitle: 'Choose the details that will identify you in this workspace.',
    },
    {
        title: 'Choose your work focus',
        subtitle: 'Select the activity that best matches your starting view.',
    },
    {
        title: 'Tune your starting topics',
        subtitle: 'Optional: choose subjects to show in your first workspace view.',
    },
    {
        title: 'Name your workspace',
        subtitle: 'Choose a name and URL for the shared space.',
    },
    {
        title: 'Pick a workspace view',
        subtitle: 'Choose the layout you want to open first.',
    },
]

const timezoneOptions = [
    { label: 'UTC-08:00 Pacific', value: 'pacific' },
    { label: 'UTC-05:00 Eastern', value: 'eastern' },
    { label: 'UTC+01:00 Central European', value: 'central-european' },
    { label: 'UTC+08:00 Singapore', value: 'singapore' },
]

const workFocusOptions = [
    { label: 'Planning', value: 'planning' },
    { label: 'Building', value: 'building' },
    { label: 'Designing', value: 'designing' },
    { label: 'Research', value: 'research' },
    { label: 'Operations', value: 'operations' },
    { label: 'Support', value: 'support' },
]


const interestOptions = [
    { label: 'Projects', value: 'projects', icon: PiKanban },
    { label: 'Notes', value: 'notes', icon: PiNote },
    { label: 'Tasks', value: 'tasks', icon: PiCheckSquare },
    { label: 'Files', value: 'files', icon: PiFileText },
    { label: 'Calendars', value: 'calendars', icon: PiCalendarBlank },
    { label: 'People', value: 'people', icon: PiUsersThree },
    { label: 'Reports', value: 'reports', icon: PiChartBar },
    { label: 'Roadmaps', value: 'roadmaps', icon: PiMapTrifold },
    { label: 'Ideas', value: 'ideas', icon: PiLightbulb },
    { label: 'Reviews', value: 'reviews', icon: PiChatCircleText },
    { label: 'Updates', value: 'updates', icon: PiBell },
    { label: 'Templates', value: 'templates', icon: PiSquaresFour },
    { label: 'Metrics', value: 'metrics', icon: PiChartLineUp },
]

const teamSizeOptions = [
    'Solo',
    '2-8',
    '9-24',
    '25-75',
    '76-250',
    '251-750',
    '751-2,500',
    '2,500+',
]

const workspaceViewOptions = [
    {
        label: 'Overview',
        value: 'overview',
        description: 'Recent activity and open work in one summary.',
        icon: PiSquaresFour,
    },
    {
        label: 'Board',
        value: 'board',
        description: 'A column for each stage, moved by drag.',
        icon: PiKanban,
    },
    {
        label: 'Timeline',
        value: 'timeline',
        description: 'Work plotted across dates and owners.',
        icon: PiChartLineUp,
    },
    {
        label: 'Table',
        value: 'table',
        description: 'Rows and columns with filters and sorting.',
        icon: PiRows,
    },
]

export default function OnboardingSetupWizard() {
    const [currentStep, setCurrentStep] = useState(0)
    const [displayName, setDisplayName] = useState('Jordan Lee')
    const [roleLabel, setRoleLabel] = useState('Operations lead')
    const [timezone, setTimezone] = useState('singapore')
    const [updatesEnabled, setUpdatesEnabled] = useState(true)
    const [workFocus, setWorkFocus] = useState('building')
    const [interests, setInterests] = useState(['projects', 'notes', 'tasks'])
    const [workspaceName, setWorkspaceName] = useState('Northstar')
    const [workspaceSlug, setWorkspaceSlug] = useState('northstar')
    const [teamSize, setTeamSize] = useState('9-24')
    const [workspaceView, setWorkspaceView] = useState('overview')

    const isComplete = currentStep === totalSteps
    const activeStep = stepDetails[currentStep]
    const progressPercent = isComplete
        ? 100
        : ((currentStep + 1) / totalSteps) * 100

    const handleNext = () => {
        setCurrentStep((step) => Math.min(step + 1, totalSteps))
    }

    const handlePrevious = () => {
        setCurrentStep((step) => Math.max(step - 1, 0))
    }

    const handleSkip = () => {
        handleNext()
    }

    const handleKeyboardAction = (
        event: ReactKeyboardEvent<HTMLElement>,
        action: () => void,
    ) => {
        if (event.key === 'Enter' || event.key === ' ') {
            event.preventDefault()
            action()
        }
    }

    const toggleInterest = (value: string) => {
        setInterests((selected) =>
            selected.includes(value)
                ? selected.filter((item) => item !== value)
                : [...selected, value],
        )
    }

    const handleViewKeyDown = (
        event: ReactKeyboardEvent<HTMLElement>,
        index: number,
    ) => {
        const { key } = event

        if (key === 'Enter' || key === ' ') {
            event.preventDefault()
            setWorkspaceView(workspaceViewOptions[index].value)
            return
        }

        const forward = key === 'ArrowRight' || key === 'ArrowDown'
        const backward = key === 'ArrowLeft' || key === 'ArrowUp'

        if (!forward && !backward) {
            return
        }

        event.preventDefault()

        const optionCount = workspaceViewOptions.length
        const nextIndex = forward
            ? (index + 1) % optionCount
            : (index - 1 + optionCount) % optionCount

        setWorkspaceView(workspaceViewOptions[nextIndex].value)

        const nextCard = event.currentTarget.parentElement?.children[nextIndex]

        if (nextCard instanceof HTMLElement) {
            nextCard.focus()
        }
    }

    const renderStepContent = () => {
        switch (currentStep) {
            case 0:
                return (
                    <div className="mt-8">
                        <Upload
                            accept="image/png,image/jpeg"
                            className="w-full"
                            showList={false}
                            uploadLimit={1}
                        >
                            <div className="mb-4 flex items-center gap-4">
                                <Avatar
                                    aria-hidden="true"
                                    icon={<PiUser />}
                                    size="lg"
                                />
                                <div className="flex min-w-0 flex-col items-start gap-2">
                                    <Button
                                        icon={<PiUploadSimple aria-hidden="true" />}
                                        size="sm"
                                        type="button"
                                    >
                                        Add image
                                    </Button>
                                </div>
                            </div>
                        </Upload>

                        <Form
                            className="mt-4"
                            onSubmit={(event) => event.preventDefault()}
                        >
                            <Form.Field
                                asterisk
                                htmlFor="onboarding-display-name"
                                label="Display name"
                            >
                                <Input
                                    id="onboarding-display-name"
                                    value={displayName}
                                    onChange={(event) =>
                                        setDisplayName(event.target.value)
                                    }
                                />
                            </Form.Field>
                            <Form.Field
                                htmlFor="onboarding-role-label"
                                label="Role label"
                            >
                                <Input
                                    id="onboarding-role-label"
                                    value={roleLabel}
                                    onChange={(event) =>
                                        setRoleLabel(event.target.value)
                                    }
                                />
                            </Form.Field>
                            <Form.Field
                                htmlFor="onboarding-timezone"
                                label="Time zone"
                            >
                                <Select
                                    inputId="onboarding-timezone"
                                    options={timezoneOptions}
                                    placeholder="Choose a time zone"
                                    value={timezoneOptions.find(
                                        (option) => option.value === timezone,
                                    )}
                                    onChange={(option) =>
                                        setTimezone(option.value as string)
                                    }
                                />
                            </Form.Field>
                            <Checkbox
                                checked={updatesEnabled}
                                onChange={(checked) =>
                                    setUpdatesEnabled(checked)
                                }
                            >
                                Account notices by email
                            </Checkbox>
                        </Form>
                    </div>
                )
            case 1:
                return (
                    <div className="mt-8 flex flex-col gap-4">
                        <p className="font-medium text-foreground">Pick one activity</p>
                        <Radio.Group
                            className="w-full gap-2"
                            name="onboarding-work-focus"
                            value={workFocus}
                            vertical
                            onChange={(value) => setWorkFocus(value as string)}
                        >
                            {workFocusOptions.map((option) => (
                                <div
                                    className={classNames(
                                        'w-full',
                                        workFocus !== option.value && 'hover:bg-accent',
                                    )}
                                    key={option.value}
                                >
                                    <Radio
                                        className="w-full rounded-control border px-4 py-2"
                                        value={option.value}
                                        onKeyDown={(event) =>
                                            handleKeyboardAction(event, () =>
                                                setWorkFocus(option.value),
                                            )
                                        }
                                    >
                                        {option.label}
                                    </Radio>
                                </div>
                            ))}
                        </Radio.Group>
                    </div>
                )
            case 2:
                return (
                    <div className="mt-8 flex flex-wrap gap-2">
                        {interestOptions.map((interest) => {
                            const selected = interests.includes(interest.value)

                            return (
                                <div
                                    className="relative inline-flex"
                                    key={interest.value}
                                >
                                    <Button
                                        aria-pressed={selected}
                                        icon={selected ? <PiCheckCircleFill className="text-xl text-primary" aria-hidden="true" /> : undefined}
                                        type="button"
                                        iconAlignment="end"
                                        onKeyDown={(event) =>
                                            handleKeyboardAction(event, () =>
                                                toggleInterest(interest.value),
                                            )
                                        }
                                        className={classNames(
                                            selected && 'bg-secondary hover:bg-secondary',
                                        )}
                                        onClick={() => toggleInterest(interest.value)}
                                    >
                                        {interest.label}
                                    </Button>
                                </div>
                            )
                        })}
                    </div>
                )
            case 3:
                return (
                    <div className="mt-8">
                        <Form
                            onSubmit={(event) => event.preventDefault()}
                        >
                            <Form.Field
                                asterisk
                                htmlFor="onboarding-workspace-name"
                                label="Workspace name"
                            >
                                <Input
                                    id="onboarding-workspace-name"
                                    value={workspaceName}
                                    onChange={(event) =>
                                        setWorkspaceName(event.target.value)
                                    }
                                />
                            </Form.Field>
                                <Form.Field
                                    asterisk
                                    htmlFor="onboarding-workspace-slug"
                                    label="Workspace slug"
                                >
                                <Input
                                    id="onboarding-workspace-slug"
                                    value={workspaceSlug}
                                    onChange={(event) =>
                                        setWorkspaceSlug(event.target.value)
                                    }
                                />
                            </Form.Field>
                        </Form>

                        <div className="mt-8">
                            <p className="mb-2 font-medium text-foreground">
                                Starting team size
                            </p>
                            <div className="flex flex-wrap gap-2">
                                {teamSizeOptions.map((option) => {
                                    const selected = teamSize === option

                                    return (
                                        <div
                                            className="relative inline-flex"
                                            key={option}
                                        >
                                            <Button
                                                aria-pressed={selected}
                                                type="button"
                                                variant={selected ? 'subtle' : 'default'}
                                                onKeyDown={(event) =>
                                                    handleKeyboardAction(event, () =>
                                                        setTeamSize(option),
                                                    )
                                                }
                                                className={classNames(
                                                    selected && 'bg-secondary hover:bg-secondary',
                                                )}
                                                icon={selected ? <PiCheckCircleFill className="text-lg text-primary" aria-hidden="true" /> : undefined}
                                                iconAlignment="end"
                                                onClick={() => setTeamSize(option)}
                                            >
                                                {option}
                                            </Button>
                                        </div>
                                    )
                                })}
                            </div>
                        </div>
                    </div>
                )
            case 4:
                return (
                    <div className="mt-8">
                        <p className="mb-2 font-medium text-foreground">Starting view</p>
                        <div
                            aria-label="Workspace starting view"
                            className="grid grid-cols-1 gap-4 sm:grid-cols-2"
                            role="radiogroup"
                        >
                            {workspaceViewOptions.map((option, index) => {
                                const selected = workspaceView === option.value
                                const ViewIcon = option.icon

                                return (
                                    <Card
                                        aria-checked={selected}
                                        className={classNames(
                                            'relative h-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
                                            selected
                                                ? 'border-primary ring'
                                                : 'hover:border-primary',
                                        )}
                                        key={option.value}
                                        role="radio"
                                        tabIndex={selected ? 0 : -1}
                                        bodyClass="flex h-full flex-col gap-8 p-4"
                                        onClick={() => setWorkspaceView(option.value)}
                                        onKeyDown={(event) =>
                                            handleViewKeyDown(event, index)
                                        }
                                    >
                                        <IconFrame size={40} variant="muted">
                                            <ViewIcon
                                                aria-hidden="true"
                                                className={classNames(
                                                    'text-xl',
                                                    selected && 'text-primary',
                                                )}
                                            />
                                        </IconFrame>
                                        <div className="space-y-1">
                                            <p
                                                className={classNames(
                                                    'font-semibold',
                                                    selected
                                                        ? 'text-primary'
                                                        : 'text-card-foreground',
                                                )}
                                            >
                                                {option.label}
                                            </p>
                                            <p className="pe-8 text-muted-foreground">
                                                {option.description}
                                            </p>
                                        </div>
                                        {selected ? (
                                            <span
                                                aria-hidden="true"
                                                className="absolute bottom-4 end-4 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground"
                                            >
                                                <PiCheck className="text-sm" />
                                            </span>
                                        ) : null}
                                    </Card>
                                )
                            })}
                        </div>
                    </div>
                )
            default:
                return null
        }
    }

    return (
        <div className="flex h-screen flex-col overflow-hidden">
            <header className="flex min-h-15 items-center gap-4 border-b px-4">
                <div className="flex min-w-0 flex-1 items-center">
                    <Logo
                        alt="NateUI"
                        imgProps={{ className: 'h-7 dark:hidden' }}
                        src={logoSrc}
                    />
                    <Logo
                        alt="NateUI"
                        imgProps={{ className: 'hidden h-7 dark:block' }}
                        src={logoWhiteSrc}
                    />
                </div>
                <div className="flex min-w-0 flex-1 justify-center">
                    <div
                        aria-label={
                            isComplete
                                ? 'Onboarding complete'
                                : `Step ${currentStep + 1} of ${totalSteps}`
                        }
                        aria-valuemax={100}
                        aria-valuemin={0}
                        aria-valuenow={progressPercent}
                        className="hidden w-full max-w-xs sm:block"
                        role="progressbar"
                    >
                        <Progress
                            percent={progressPercent}
                            showInfo={false}
                            size="sm"
                        />
                    </div>
                </div>
                <div className="flex min-w-0 flex-1 justify-end">
                    {isComplete ? (
                        <span className="flex items-center gap-2 whitespace-nowrap">
                            <PiCheckCircleFill aria-hidden="true" className="text-base text-success" />
                            Setup complete
                        </span>
                    ) : (
                        <span className="whitespace-nowrap">
                            {currentStep + 1} / {totalSteps}
                        </span>
                    )}
                </div>
            </header>

            {isComplete ? (
                <main className="flex flex-1 min-h-0 flex-col">
                    <Container
                        asElement="div"
                        className="flex w-full max-w-xl flex-1 flex-col items-center justify-center px-4 text-center sm:px-8"
                        size="lg"
                    >
                        <div className="rounded-full bg-palette-emerald-soft p-4">
                            <IconFrame
                                className="rounded-full bg-palette-emerald text-success/30 drop-shadow-[0_4px_6px_currentColor]"
                                size={64}
                                variant="muted"
                            >
                                <PiCheckBold
                                    aria-hidden="true"
                                    className="text-3xl text-primary-foreground"
                                />
                            </IconFrame>
                        </div>
                        <h1 className="mt-8 text-2xl font-semibold text-foreground">
                            Ready to go!
                        </h1>
                        <p className="mt-1 text-muted-foreground">
                            The workspace can now be opened for the first task.
                        </p>
                        <div className="flex w-full max-w-sm flex-col items-center gap-4 mt-8">
                            <Button block type="button" variant="solid">
                                Open workspace
                            </Button>
                            <Button
                                type="button"
                                variant="ghost"
                                block
                                onClick={() => setCurrentStep(totalSteps - 1)}
                            >
                                Review selections
                            </Button>
                        </div>
                    </Container>
                </main>
            ) : (
                <>
                    <main className="flex min-h-0 flex-1 flex-col overflow-y-auto">
                        <Container
                            asElement="div"
                            className="flex min-h-0 w-full max-w-xl flex-1 flex-col justify-center px-4 py-8 sm:px-8"
                            size="lg"
                        >
                            <div aria-live="polite">
                                <h4
                                    className="text-xl font-semibold text-foreground"
                                >
                                    {activeStep.title}
                                </h4>
                                <p className="mt-1 text-muted-foreground">
                                    {activeStep.subtitle}
                                </p>
                            </div>
                            {renderStepContent()}
                        </Container>
                    </main>
                    <footer className="border-t py-2 min-h-15 flex items-center">
                        <Container
                            asElement="div"
                            className="flex w-full max-w-xl flex-wrap items-center justify-between gap-4 px-4 sm:px-8"
                            size="lg"
                        >
                            {currentStep === 0 ? (
                                <span aria-hidden="true" />
                            ) : (
                                <Button
                                    icon={<PiArrowLeft aria-hidden="true" />}
                                    iconAlignment="start"
                                    type="button"
                                    variant="ghost"
                                    onKeyDown={(event) =>
                                        handleKeyboardAction(event, handlePrevious)
                                    }
                                    onClick={handlePrevious}
                                >
                                    Previous
                                </Button>
                            )}
                            <div className="flex flex-wrap items-center gap-2">
                                {currentStep > 0 && (
                                    <Button
                                        type="button"
                                        variant="ghost"
                                        onKeyDown={(event) =>
                                            handleKeyboardAction(event, handleSkip)
                                        }
                                        onClick={handleSkip}
                                    >
                                        Skip
                                    </Button>
                                )}
                                <Button
                                    icon={<PiArrowRight aria-hidden="true" />}
                                    iconAlignment="end"
                                    type="button"
                                    variant="solid"
                                    onKeyDown={(event) =>
                                        handleKeyboardAction(event, handleNext)
                                    }
                                    onClick={handleNext}
                                >
                                    Next step
                                </Button>
                            </div>
                        </Container>
                    </footer>
                </>
            )}
        </div>
    )
}

Onboarding 02

Preview
npx nateui@latest add OnboardingSetupAppShell
Dark
import { useState } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
import AppShellSeamlessSide from '@/components/layouts/AppShellSeamlessSide'
import Header from '@/components/layouts/Header'
import Logo from '@/components/layouts/Logo'
import PageContainer from '@/components/layouts/PageContainer'
import Container from '@/components/composites/Container'
import IconFrame from '@/components/composites/IconFrame'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
import Checkbox from '@/components/ui/Checkbox'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Radio from '@/components/ui/Radio'
import Select from '@/components/ui/Select'
import Steps from '@/components/ui/Steps'
import Upload from '@/components/ui/Upload'
import classNames from '@/utils/classNames'
import {
    PiArrowLeft,
    PiArrowRight,
    PiBell,
    PiCalendarBlank,
    PiChartBar,
    PiChartLineUp,
    PiCheck,
    PiCheckBold,
    PiCheckCircleFill,
    PiCheckSquare,
    PiFileText,
    PiKanban,
    PiMapTrifold,
    PiNote,
    PiRows,
    PiSquaresFour,
    PiUploadSimple,
    PiUser,
    PiUsersThree,
    PiQuestion
} from 'react-icons/pi'

const assetBase = 'https://statics.nateui.com/img'
const logoSrc = `${assetBase}/logos/logo.svg`
const logoWhiteSrc = `${assetBase}/logos/logo-white.svg`

const totalSteps = 5

const stepDetails = [
    {
        title: 'Add profile details',
        subtitle: 'Choose the details that will identify you in this workspace.',
    },
    {
        title: 'Choose your work focus',
        subtitle: 'Select the activity that best matches your starting view.',
    },
    {
        title: 'Tune your starting topics',
        subtitle: 'Optional: choose subjects to show in your first workspace view.',
    },
    {
        title: 'Name your workspace',
        subtitle: 'Choose a name and URL for the shared space.',
    },
    {
        title: 'Pick a workspace view',
        subtitle: 'Choose the layout you want to open first.',
    },
]

const sidebarStepDetails = [
    { title: 'Account', description: 'Name and time zone.' },
    { title: 'Work focus', description: 'Choose a starting activity.' },
    { title: 'Topics', description: 'Optional workspace subjects.' },
    { title: 'Workspace', description: 'Name and team size.' },
    { title: 'Starting view', description: 'Pick an opening layout.' },
]

const timezoneOptions = [
    { label: 'UTC-08:00 Pacific', value: 'pacific' },
    { label: 'UTC-05:00 Eastern', value: 'eastern' },
    { label: 'UTC+01:00 Central European', value: 'central-european' },
    { label: 'UTC+08:00 Singapore', value: 'singapore' },
]

const workFocusOptions = [
    { label: 'Planning', value: 'planning' },
    { label: 'Building', value: 'building' },
    { label: 'Designing', value: 'designing' },
    { label: 'Research', value: 'research' },
    { label: 'Operations', value: 'operations' },
    { label: 'Support', value: 'support' },
]

const interestOptions = [
    { label: 'Projects', value: 'projects', icon: PiKanban },
    { label: 'Notes', value: 'notes', icon: PiNote },
    { label: 'Tasks', value: 'tasks', icon: PiCheckSquare },
    { label: 'Files', value: 'files', icon: PiFileText },
    { label: 'Calendars', value: 'calendars', icon: PiCalendarBlank },
    { label: 'People', value: 'people', icon: PiUsersThree },
    { label: 'Reports', value: 'reports', icon: PiChartBar },
    { label: 'Roadmaps', value: 'roadmaps', icon: PiMapTrifold },
    { label: 'Ideas', value: 'ideas', icon: PiSquaresFour },
    { label: 'Reviews', value: 'reviews', icon: PiNote },
    { label: 'Updates', value: 'updates', icon: PiBell },
    { label: 'Templates', value: 'templates', icon: PiSquaresFour },
    { label: 'Metrics', value: 'metrics', icon: PiChartLineUp },
]

const teamSizeOptions = [
    'Solo',
    '2-8',
    '9-24',
    '25-75',
    '76-250',
    '251-750',
    '751-2,500',
    '2,500+',
]

const workspaceViewOptions = [
    {
        label: 'Overview',
        value: 'overview',
        description: 'Recent activity and open work in one summary.',
        icon: PiSquaresFour,
    },
    {
        label: 'Board',
        value: 'board',
        description: 'A column for each stage, moved by drag.',
        icon: PiKanban,
    },
    {
        label: 'Timeline',
        value: 'timeline',
        description: 'Work plotted across dates and owners.',
        icon: PiChartLineUp,
    },
    {
        label: 'Table',
        value: 'table',
        description: 'Rows and columns with filters and sorting.',
        icon: PiRows,
    },
]

export default function OnboardingSetupAppShell() {
    const [currentStep, setCurrentStep] = useState(0)
    const [displayName, setDisplayName] = useState('Jordan Lee')
    const [roleLabel, setRoleLabel] = useState('Operations lead')
    const [timezone, setTimezone] = useState('singapore')
    const [updatesEnabled, setUpdatesEnabled] = useState(true)
    const [workFocus, setWorkFocus] = useState('building')
    const [interests, setInterests] = useState(['projects', 'notes', 'tasks'])
    const [workspaceName, setWorkspaceName] = useState('Northstar')
    const [workspaceSlug, setWorkspaceSlug] = useState('northstar')
    const [teamSize, setTeamSize] = useState('9-24')
    const [workspaceView, setWorkspaceView] = useState('overview')

    const isComplete = currentStep === totalSteps
    const activeStep = stepDetails[currentStep]
    const progressPercent = isComplete
        ? 100
        : ((currentStep + 1) / totalSteps) * 100

    const handleNext = () => {
        setCurrentStep((step) => Math.min(step + 1, totalSteps))
    }

    const handlePrevious = () => {
        setCurrentStep((step) => Math.max(step - 1, 0))
    }

    const handleSkip = () => {
        handleNext()
    }

    const handleTimelineStepChange = (step: number) => {
        if (step < currentStep) {
            setCurrentStep(step)
        }
    }

    const handleKeyboardAction = (
        event: ReactKeyboardEvent<HTMLElement>,
        action: () => void,
    ) => {
        if (event.key === 'Enter' || event.key === ' ') {
            event.preventDefault()
            action()
        }
    }

    const toggleInterest = (value: string) => {
        setInterests((selected) =>
            selected.includes(value)
                ? selected.filter((item) => item !== value)
                : [...selected, value],
        )
    }

    const handleViewKeyDown = (
        event: ReactKeyboardEvent<HTMLElement>,
        index: number,
    ) => {
        const { key } = event

        if (key === 'Enter' || key === ' ') {
            event.preventDefault()
            setWorkspaceView(workspaceViewOptions[index].value)
            return
        }

        const forward = key === 'ArrowRight' || key === 'ArrowDown'
        const backward = key === 'ArrowLeft' || key === 'ArrowUp'

        if (!forward && !backward) {
            return
        }

        event.preventDefault()

        const optionCount = workspaceViewOptions.length
        const nextIndex = forward
            ? (index + 1) % optionCount
            : (index - 1 + optionCount) % optionCount

        setWorkspaceView(workspaceViewOptions[nextIndex].value)

        const nextCard = event.currentTarget.parentElement?.children[nextIndex]

        if (nextCard instanceof HTMLElement) {
            nextCard.focus()
        }
    }

    const renderStepContent = () => {
        switch (currentStep) {
            case 0:
                return (
                    <div className="mt-8">
                        <Upload
                            accept="image/png,image/jpeg"
                            aria-label="Upload profile image"
                            className="w-full"
                            showList={false}
                            uploadLimit={1}
                        >
                            <div className="flex items-center gap-4">
                                <Avatar
                                    aria-hidden="true"
                                    icon={<PiUser />}
                                    size="lg"
                                />
                                <div className="flex min-w-0 flex-col items-start gap-2">
                                    <Button
                                        icon={<PiUploadSimple aria-hidden="true" />}
                                        size="sm"
                                        type="button"
                                    >
                                        Add image
                                    </Button>
                                </div>
                            </div>
                        </Upload>

                        <Form
                            className="mt-4"
                            onSubmit={(event) => event.preventDefault()}
                        >
                            <Form.Field
                                asterisk
                                htmlFor="onboarding-app-shell-display-name"
                                label="Display name"
                            >
                                <Input
                                    id="onboarding-app-shell-display-name"
                                    value={displayName}
                                    onChange={(event) =>
                                        setDisplayName(event.target.value)
                                    }
                                />
                            </Form.Field>
                            <Form.Field
                                htmlFor="onboarding-app-shell-role-label"
                                label="Role label"
                            >
                                <Input
                                    id="onboarding-app-shell-role-label"
                                    value={roleLabel}
                                    onChange={(event) =>
                                        setRoleLabel(event.target.value)
                                    }
                                />
                            </Form.Field>
                            <Form.Field
                                htmlFor="onboarding-app-shell-timezone"
                                label="Time zone"
                            >
                                <Select
                                    inputId="onboarding-app-shell-timezone"
                                    options={timezoneOptions}
                                    placeholder="Choose a time zone"
                                    value={timezoneOptions.find(
                                        (option) => option.value === timezone,
                                    )}
                                    onChange={(option) =>
                                        setTimezone(option.value as string)
                                    }
                                />
                            </Form.Field>
                            <Checkbox
                                checked={updatesEnabled}
                                onChange={(checked) =>
                                    setUpdatesEnabled(checked)
                                }
                            >
                                Account notices by email
                            </Checkbox>
                        </Form>
                    </div>
                )
            case 1:
                return (
                    <div className="mt-8 flex flex-col gap-4">
                        <p className="font-medium text-foreground">Pick one activity</p>
                        <Radio.Group
                            className="w-full gap-2"
                            name="onboarding-app-shell-work-focus"
                            value={workFocus}
                            vertical
                            onChange={(value) => setWorkFocus(value as string)}
                        >
                            {workFocusOptions.map((option) => (
                                <div
                                    className={classNames(
                                        'w-full',
                                        workFocus !== option.value && 'hover:bg-accent',
                                    )}
                                    key={option.value}
                                >
                                    <Radio
                                        className="w-full rounded-control border px-4 py-2"
                                        value={option.value}
                                        onKeyDown={(event) =>
                                            handleKeyboardAction(event, () =>
                                                setWorkFocus(option.value),
                                            )
                                        }
                                    >
                                        {option.label}
                                    </Radio>
                                </div>
                            ))}
                        </Radio.Group>
                    </div>
                )
            case 2:
                return (
                    <div className="mt-8 flex flex-wrap gap-2">
                        {interestOptions.map((interest) => {
                            const selected = interests.includes(interest.value)

                            return (
                                <div
                                    className="relative inline-flex"
                                    key={interest.value}
                                >
                                    <Button
                                        aria-pressed={selected}
                                        icon={selected ? <PiCheckCircleFill className="text-xl text-primary" aria-hidden="true" /> : undefined}
                                        type="button"
                                        iconAlignment="end"
                                        onKeyDown={(event) =>
                                            handleKeyboardAction(event, () =>
                                                toggleInterest(interest.value),
                                            )
                                        }
                                        className={classNames(
                                            selected && 'bg-secondary hover:bg-secondary',
                                        )}
                                        onClick={() => toggleInterest(interest.value)}
                                    >
                                        {interest.label}
                                    </Button>
                                </div>
                            )
                        })}
                    </div>
                )
            case 3:
                return (
                    <div className="mt-8">
                        <Form
                            onSubmit={(event) => event.preventDefault()}
                        >
                            <Form.Field
                                asterisk
                                htmlFor="onboarding-app-shell-workspace-name"
                                label="Workspace name"
                            >
                                <Input
                                    id="onboarding-app-shell-workspace-name"
                                    value={workspaceName}
                                    onChange={(event) =>
                                        setWorkspaceName(event.target.value)
                                    }
                                />
                            </Form.Field>
                            <Form.Field
                                asterisk
                                htmlFor="onboarding-app-shell-workspace-slug"
                                label="Workspace slug"
                            >
                                <Input
                                    id="onboarding-app-shell-workspace-slug"
                                    value={workspaceSlug}
                                    onChange={(event) =>
                                        setWorkspaceSlug(event.target.value)
                                    }
                                />
                            </Form.Field>
                        </Form>

                        <div className="mt-8">
                            <p className="mb-2 font-medium text-foreground">
                                Starting team size
                            </p>
                            <div className="flex flex-wrap gap-2">
                                {teamSizeOptions.map((option) => {
                                    const selected = teamSize === option

                                    return (
                                        <div
                                            className="relative inline-flex"
                                            key={option}
                                        >
                                            <Button
                                                aria-pressed={selected}
                                                type="button"
                                                variant={selected ? 'subtle' : 'default'}
                                                onKeyDown={(event) =>
                                                    handleKeyboardAction(event, () =>
                                                        setTeamSize(option),
                                                    )
                                                }
                                                className={classNames(
                                                    selected && 'bg-secondary hover:bg-secondary',
                                                )}
                                                icon={selected ? <PiCheckCircleFill className="text-lg text-primary" aria-hidden="true" /> : undefined}
                                                iconAlignment="end"
                                                onClick={() => setTeamSize(option)}
                                            >
                                                {option}
                                            </Button>
                                        </div>
                                    )
                                })}
                            </div>
                        </div>
                    </div>
                )
            case 4:
                return (
                    <div className="mt-8">
                        <p className="mb-2 font-medium text-foreground">Starting view</p>
                        <div
                            aria-label="Workspace starting view"
                            className="grid grid-cols-1 gap-4 sm:grid-cols-2"
                            role="radiogroup"
                        >
                            {workspaceViewOptions.map((option, index) => {
                                const selected = workspaceView === option.value
                                const ViewIcon = option.icon

                                return (
                                    <Card
                                        aria-checked={selected}
                                        className={classNames(
                                            'relative h-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
                                            selected
                                                ? 'border-primary ring'
                                                : 'hover:border-primary',
                                        )}
                                        key={option.value}
                                        role="radio"
                                        tabIndex={selected ? 0 : -1}
                                        bodyClass="flex h-full flex-col gap-8 p-4"
                                        onClick={() => setWorkspaceView(option.value)}
                                        onKeyDown={(event) =>
                                            handleViewKeyDown(event, index)
                                        }
                                    >
                                        <IconFrame size={40} variant="muted">
                                            <ViewIcon
                                                aria-hidden="true"
                                                className={classNames(
                                                    'text-xl',
                                                    selected && 'text-primary',
                                                )}
                                            />
                                        </IconFrame>
                                        <div className="space-y-1">
                                            <p
                                                className={classNames(
                                                    'font-semibold',
                                                    selected
                                                        ? 'text-primary'
                                                        : 'text-card-foreground',
                                                )}
                                            >
                                                {option.label}
                                            </p>
                                            <p className="pe-8 text-muted-foreground">
                                                {option.description}
                                            </p>
                                        </div>
                                        {selected ? (
                                            <span
                                                aria-hidden="true"
                                                className="absolute bottom-4 end-4 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground"
                                            >
                                                <PiCheck className="text-sm" />
                                            </span>
                                        ) : null}
                                    </Card>
                                )
                            })}
                        </div>
                    </div>
                )
            default:
                return null
        }
    }

    const renderStepActions = () => (
        <div className="mt-8 flex flex-wrap items-center justify-between gap-4 pt-4">
            {currentStep === 0 ? (
                <span aria-hidden="true" />
            ) : (
                <Button
                    icon={<PiArrowLeft aria-hidden="true" />}
                    iconAlignment="start"
                    type="button"
                    variant="ghost"
                    onKeyDown={(event) =>
                        handleKeyboardAction(event, handlePrevious)
                    }
                    onClick={handlePrevious}
                >
                    Previous
                </Button>
            )}
            <div
                className={classNames(
                    'flex flex-wrap items-center gap-2',
                    currentStep === 0 && 'w-full',
                )}
            >
                {currentStep > 0 && (
                    <Button
                        type="button"
                        variant="ghost"
                        onKeyDown={(event) =>
                            handleKeyboardAction(event, handleSkip)
                        }
                        onClick={handleSkip}
                    >
                        Skip
                    </Button>
                )}
                <Button
                    block={currentStep === 0}
                    icon={<PiArrowRight aria-hidden="true" />}
                    iconAlignment="end"
                    type="button"
                    variant="solid"
                    onKeyDown={(event) =>
                        handleKeyboardAction(event, handleNext)
                    }
                    onClick={handleNext}
                >
                    Next step
                </Button>
            </div>
        </div>
    )

    return (
        <AppShellSeamlessSide
            sidebar={
                <div className="hidden h-full bg-card p-2 lg:block">
                    <aside
                        aria-label="Onboarding setup progress"
                        className="flex h-[calc(100vh-1rem)] w-80 flex-col rounded-card bg-primary p-8 text-primary-foreground relative"
                    >
                        <img
                            alt=""
                            className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-5 select-none"
                            src={`${assetBase}/thumbs/misc/img-16.png`}
                        />
                        <div className="flex items-center">
                            <Logo
                                alt="NateUI"
                                imgProps={{ className: 'h-7' }}
                                src={logoWhiteSrc}
                            />
                        </div>
                        <div className="flex flex-1 items-center py-8">
                            <div className="w-full" aria-label="Setup stages">
                                <Steps
                                    aria-label="Setup stages"
                                    className="[&_.step-connect]:bg-primary-foreground/30 [&_.step-item-icon-complete]:bg-primary-foreground [&_.step-item-icon-complete]:text-primary [&_.step-item-icon-current]:border-primary-foreground [&_.step-item-icon-current]:text-primary-foreground [&_.step-item-icon-pending]:border-primary-foreground/70 [&_.step-item-title:hover]:text-primary-foreground"
                                    current={isComplete ? totalSteps : currentStep}
                                    vertical
                                >
                                    {sidebarStepDetails.map((step, index) => {
                                        const isCompleted =
                                            index <
                                            (isComplete
                                                ? totalSteps
                                                : currentStep)

                                        return (
                                            <Steps.Item
                                                key={step.title}
                                                customIcon={
                                                    isCompleted
                                                        ? undefined
                                                        : index === currentStep
                                                          ? <span className="h-2 w-2 rounded-full bg-primary-foreground" aria-hidden="true" />
                                                          : <span aria-hidden="true" />
                                                }
                                                description={
                                                    <span className="opacity-80">{step.description}</span>
                                                }
                                                onStepChange={
                                                    isCompleted
                                                        ? () =>
                                                              handleTimelineStepChange(
                                                                  index,
                                                              )
                                                        : undefined
                                                }
                                                title={step.title}
                                            />
                                        )
                                    })}
                                </Steps>
                            </div>
                        </div>
                        <div className="flex items-center justify-between gap-2 pt-4 text-xs">
                            <span className="flex items-center gap-1">
                                <span className="flex items-center gap-2">
                                    <PiQuestion className="text-base" />
                                    <span>Need help?</span>
                                </span>
                                <a
                                    className="rounded-sm hover:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring text-primary-foreground/80 underline"
                                    href="#help"
                                >
                                    Contact Support
                                </a>
                            </span>
                        </div>
                    </aside>
                </div>
            }
            header={
                <Header
                    className="lg:hidden"
                    headerStart={[
                        {
                            component: (
                                <div className="flex items-center">
                                    <Logo
                                        alt="NateUI"
                                        imgProps={{ className: 'h-7 dark:hidden' }}
                                        src={logoSrc}
                                    />
                                    <Logo
                                        alt="NateUI"
                                        imgProps={{ className: 'hidden h-7 dark:block' }}
                                        src={logoWhiteSrc}
                                    />
                                </div>
                            ),
                        },
                    ]}
                />
            }
        >
            <PageContainer footer={false}>
                <div className="flex min-h-full flex-col">
                    <Container
                        asElement="div"
                        className="flex min-h-full w-full max-w-md flex-col"
                        size="sm"
                    >
                        <div className="flex flex-1 flex-col justify-center py-8">
                            {isComplete ? (
                                <div className="flex flex-col items-center text-center">
                                    <div className="rounded-full bg-palette-emerald-soft p-4">
                                        <IconFrame
                                            className="rounded-full bg-palette-emerald text-success/30 drop-shadow-[0_4px_6px_currentColor]"
                                            size={64}
                                            variant="muted"
                                        >
                                            <PiCheckBold
                                                aria-hidden="true"
                                                className="text-3xl text-primary-foreground"
                                            />
                                        </IconFrame>
                                    </div>
                                    <h1 className="mt-8 text-2xl font-semibold text-foreground">
                                        Ready to go!
                                    </h1>
                                    <p className="mt-1 text-muted-foreground">
                                        The workspace can now be opened for the first task.
                                    </p>
                                    <div className="mt-8 flex w-full max-w-sm flex-col items-center gap-4">
                                        <Button block type="button" variant="solid">
                                            Open workspace
                                        </Button>
                                        <Button
                                            block
                                            type="button"
                                            variant="ghost"
                                            onClick={() => setCurrentStep(totalSteps - 1)}
                                        >
                                            Review selections
                                        </Button>
                                    </div>
                                </div>
                            ) : (
                                <>
                                    <div aria-live="polite">
                                        <h4 className="text-xl font-semibold text-foreground">
                                            {activeStep.title}
                                        </h4>
                                        <p className="mt-1 text-muted-foreground">
                                            {activeStep.subtitle}
                                        </p>
                                    </div>
                                    {renderStepContent()}
                                    {renderStepActions()}
                                </>
                            )}
                        </div>
                    </Container>
                </div>
            </PageContainer>
        </AppShellSeamlessSide>
    )
}

Onboarding 03

Preview
npx nateui@latest add OnboardingSideSplit
Dark
import { useState } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
import AuthShellSplit from '@/components/layouts/AuthShellSplit'
import Container from '@/components/composites/Container'
import IconFrame from '@/components/composites/IconFrame'
import classNames from '@/utils/classNames'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Checkbox from '@/components/ui/Checkbox'
import Form from '@/components/ui/Form'
import Input from '@/components/ui/Input'
import Progress from '@/components/ui/Progress'
import Radio from '@/components/ui/Radio'
import Select from '@/components/ui/Select'
import Upload from '@/components/ui/Upload'
import {
    PiArrowLeft,
    PiArrowRight,
    PiBell,
    PiCalendarBlank,
    PiChartBar,
    PiChartLineUp,
    PiChatCircleText,
    PiCheck,
    PiCheckBold,
    PiCheckCircleFill,
    PiCheckSquare,
    PiFileText,
    PiKanban,
    PiLightbulb,
    PiMapTrifold,
    PiNote,
    PiRows,
    PiSquaresFour,
    PiUploadSimple,
    PiUser,
    PiUsersThree,
} from 'react-icons/pi'

const assetBase = 'https://statics.nateui.com/img'
const logoSrc = `${assetBase}/logos/logo-collapsed.svg`
const logoWhiteSrc = `${assetBase}/logos/logo-white-collapsed.svg`
const mediaSrc = `${assetBase}/thumbs/misc/img-30.png`
const backgroundSrc = `${assetBase}/thumbs/misc/img-31.png`

const totalSteps = 5

const stepDetails = [
    {
        title: 'Add profile details',
        subtitle: 'Choose the details that  identify you in this workspace.',
    },
    {
        title: 'Choose your work focus',
        subtitle: 'Select the activity that best matches your starting view.',
    },
    {
        title: 'Tune your starting topics',
        subtitle: 'Optional: choose subjects to show in your first workspace view.',
    },
    {
        title: 'Name your workspace',
        subtitle: 'Choose a name and URL for the shared space.',
    },
    {
        title: 'Pick a workspace view',
        subtitle: 'Choose the layout you want to open first.',
    },
]

const timezoneOptions = [
    { label: 'UTC-08:00 Pacific', value: 'pacific' },
    { label: 'UTC-05:00 Eastern', value: 'eastern' },
    { label: 'UTC+01:00 Central European', value: 'central-european' },
    { label: 'UTC+08:00 Singapore', value: 'singapore' },
]

const workFocusOptions = [
    { label: 'Planning', value: 'planning' },
    { label: 'Building', value: 'building' },
    { label: 'Designing', value: 'designing' },
    { label: 'Research', value: 'research' },
    { label: 'Operations', value: 'operations' },
    { label: 'Support', value: 'support' },
]

const interestOptions = [
    { label: 'Projects', value: 'projects', icon: PiKanban },
    { label: 'Notes', value: 'notes', icon: PiNote },
    { label: 'Tasks', value: 'tasks', icon: PiCheckSquare },
    { label: 'Files', value: 'files', icon: PiFileText },
    { label: 'Calendars', value: 'calendars', icon: PiCalendarBlank },
    { label: 'People', value: 'people', icon: PiUsersThree },
    { label: 'Reports', value: 'reports', icon: PiChartBar },
    { label: 'Roadmaps', value: 'roadmaps', icon: PiMapTrifold },
    { label: 'Ideas', value: 'ideas', icon: PiLightbulb },
    { label: 'Reviews', value: 'reviews', icon: PiChatCircleText },
    { label: 'Updates', value: 'updates', icon: PiBell },
    { label: 'Templates', value: 'templates', icon: PiSquaresFour },
    { label: 'Metrics', value: 'metrics', icon: PiChartLineUp },
]

const teamSizeOptions = [
    'Solo',
    '2-8',
    '9-24',
    '25-75',
    '76-250',
    '251-750',
    '751-2,500',
    '2,500+',
]

const workspaceViewOptions = [
    {
        label: 'Overview',
        value: 'overview',
        description: 'Recent activity and open work in one summary.',
        icon: PiSquaresFour,
    },
    {
        label: 'Board',
        value: 'board',
        description: 'A column for each stage, moved by drag.',
        icon: PiKanban,
    },
    {
        label: 'Timeline',
        value: 'timeline',
        description: 'Work plotted across dates and owners.',
        icon: PiChartLineUp,
    },
    {
        label: 'Table',
        value: 'table',
        description: 'Rows and columns with filters and sorting.',
        icon: PiRows,
    },
]

const focusRingClass =
    'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background'

export default function OnboardingSideSplit() {
    const [currentStep, setCurrentStep] = useState(0)
    const [displayName, setDisplayName] = useState('Jordan Lee')
    const [roleLabel, setRoleLabel] = useState('Operations lead')
    const [timezone, setTimezone] = useState('singapore')
    const [updatesEnabled, setUpdatesEnabled] = useState(true)
    const [workFocus, setWorkFocus] = useState('building')
    const [interests, setInterests] = useState(['projects', 'notes', 'tasks'])
    const [workspaceName, setWorkspaceName] = useState('Northstar')
    const [workspaceSlug, setWorkspaceSlug] = useState('northstar')
    const [teamSize, setTeamSize] = useState('9-24')
    const [workspaceView, setWorkspaceView] = useState('overview')

    const isComplete = currentStep === totalSteps
    const activeStep = stepDetails[currentStep]
    const progressPercent = isComplete
        ? 100
        : ((currentStep + 1) / totalSteps) * 100

    const handleNext = () => {
        setCurrentStep((step) => Math.min(step + 1, totalSteps))
    }

    const handlePrevious = () => {
        setCurrentStep((step) => Math.max(step - 1, 0))
    }

    const handleKeyboardAction = (
        event: ReactKeyboardEvent<HTMLElement>,
        action: () => void,
    ) => {
        if (event.key === 'Enter' || event.key === ' ') {
            event.preventDefault()
            action()
        }
    }

    const handleWorkFocusKeyDown = (
        event: ReactKeyboardEvent<HTMLElement>,
        index: number,
    ) => {
        if (event.key === 'Enter' || event.key === ' ') {
            event.preventDefault()
            setWorkFocus(workFocusOptions[index].value)
            return
        }

        const forward = event.key === 'ArrowDown' || event.key === 'ArrowRight'
        const backward = event.key === 'ArrowUp' || event.key === 'ArrowLeft'

        if (!forward && !backward) {
            return
        }

        event.preventDefault()

        const optionCount = workFocusOptions.length
        const nextIndex = forward
            ? (index + 1) % optionCount
            : (index - 1 + optionCount) % optionCount

        setWorkFocus(workFocusOptions[nextIndex].value)

        const nextRadio = event.currentTarget
            .closest('.radio-group')
            ?.querySelectorAll('input[type="radio"]')[nextIndex]

        if (nextRadio instanceof HTMLElement) {
            nextRadio.focus()
        }
    }

    const toggleInterest = (value: string) => {
        setInterests((selected) =>
            selected.includes(value)
                ? selected.filter((item) => item !== value)
                : [...selected, value],
        )
    }

    const handleViewKeyDown = (
        event: ReactKeyboardEvent<HTMLElement>,
        index: number,
    ) => {
        const { key } = event

        if (key === 'Enter' || key === ' ') {
            event.preventDefault()
            setWorkspaceView(workspaceViewOptions[index].value)
            return
        }

        const forward = key === 'ArrowRight' || key === 'ArrowDown'
        const backward = key === 'ArrowLeft' || key === 'ArrowUp'

        if (!forward && !backward) {
            return
        }

        event.preventDefault()

        const optionCount = workspaceViewOptions.length
        const nextIndex = forward
            ? (index + 1) % optionCount
            : (index - 1 + optionCount) % optionCount

        setWorkspaceView(workspaceViewOptions[nextIndex].value)

        const nextCard = event.currentTarget.parentElement?.children[nextIndex]

        if (nextCard instanceof HTMLElement) {
            nextCard.focus()
        }
    }

    const renderStepContent = () => {
        switch (currentStep) {
            case 0:
                return (
                    <div className="mt-8">
                        <Upload
                            accept="image/png,image/jpeg"
                            className="w-full"
                            showList={false}
                            uploadLimit={1}
                        >
                            <div className="mb-4 flex items-center gap-4">
                                <Avatar
                                    aria-hidden="true"
                                    icon={<PiUser />}
                                    size="lg"
                                />
                                <div className="flex min-w-0 flex-col items-start gap-2">
                                    <Button
                                        className={focusRingClass}
                                        icon={<PiUploadSimple aria-hidden="true" />}
                                        size="sm"
                                        type="button"
                                    >
                                        Add image
                                    </Button>
                                </div>
                            </div>
                        </Upload>

                        <Form
                            className="mt-4"
                            onSubmit={(event) => event.preventDefault()}
                        >
                            <Form.Field
                                asterisk
                                htmlFor="onboarding-side-split-display-name"
                                label="Display name"
                            >
                                <Input
                                    id="onboarding-side-split-display-name"
                                    value={displayName}
                                    onChange={(event) =>
                                        setDisplayName(event.target.value)
                                    }
                                />
                            </Form.Field>
                            <Form.Field
                                htmlFor="onboarding-side-split-role-label"
                                label="Role label"
                            >
                                <Input
                                    id="onboarding-side-split-role-label"
                                    value={roleLabel}
                                    onChange={(event) =>
                                        setRoleLabel(event.target.value)
                                    }
                                />
                            </Form.Field>
                            <Form.Field
                                htmlFor="onboarding-side-split-timezone"
                                label="Time zone"
                                labelId="onboarding-side-split-timezone"
                            >
                                <Select
                                    inputId="onboarding-side-split-timezone"
                                    options={timezoneOptions}
                                    placeholder="Choose a time zone"
                                    value={timezoneOptions.find(
                                        (option) => option.value === timezone,
                                    )}
                                    onChange={(option) =>
                                        setTimezone(option.value as string)
                                    }
                                />
                            </Form.Field>
                            <Checkbox
                                checked={updatesEnabled}
                                onChange={(checked) =>
                                    setUpdatesEnabled(checked)
                                }
                            >
                                Account notices by email
                            </Checkbox>
                        </Form>
                    </div>
                )
            case 1:
                return (
                    <div className="mt-8 flex flex-col gap-4">
                        <p className="font-medium text-foreground">Pick one activity</p>
                        <Radio.Group
                            className="w-full gap-2"
                            name="onboarding-side-split-work-focus"
                            value={workFocus}
                            vertical
                            onChange={(value) => setWorkFocus(value as string)}
                        >
                            {workFocusOptions.map((option, index) => (
                                <div
                                    className={classNames(
                                        'w-full rounded-control',
                                        workFocus !== option.value && 'hover:bg-accent',
                                    )}
                                    key={option.value}
                                >
                                    <Radio
                                        className={classNames(
                                            'w-full rounded-control border px-4 py-2',
                                            focusRingClass,
                                        )}
                                        tabIndex={
                                            workFocus === option.value ? 0 : -1
                                        }
                                        value={option.value}
                                        onKeyDown={(event) =>
                                            handleWorkFocusKeyDown(event, index)
                                        }
                                    >
                                        {option.label}
                                    </Radio>
                                </div>
                            ))}
                        </Radio.Group>
                    </div>
                )
            case 2:
                return (
                    <div className="mt-8 flex flex-wrap gap-2">
                        {interestOptions.map((interest) => {
                            const selected = interests.includes(interest.value)

                            return (
                                <div
                                    className="relative inline-flex"
                                    key={interest.value}
                                >
                                    <Button
                                        aria-pressed={selected}
                                        className={focusRingClass}
                                        icon={
                                            selected ? (
                                                <PiCheckCircleFill
                                                    aria-hidden="true"
                                                    className="text-xl text-primary"
                                                />
                                            ) : undefined
                                        }
                                        type="button"
                                        iconAlignment="end"
                                        onKeyDown={(event) =>
                                            handleKeyboardAction(event, () =>
                                                toggleInterest(interest.value),
                                            )
                                        }
                                        onClick={() => toggleInterest(interest.value)}
                                    >
                                        {interest.label}
                                    </Button>
                                </div>
                            )
                        })}
                    </div>
                )
            case 3:
                return (
                    <div className="mt-8">
                        <Form
                            onSubmit={(event) => event.preventDefault()}
                        >
                            <Form.Field
                                asterisk
                                htmlFor="onboarding-side-split-workspace-name"
                                label="Workspace name"
                            >
                                <Input
                                    id="onboarding-side-split-workspace-name"
                                    value={workspaceName}
                                    onChange={(event) =>
                                        setWorkspaceName(event.target.value)
                                    }
                                />
                            </Form.Field>
                            <Form.Field
                                asterisk
                                htmlFor="onboarding-side-split-workspace-slug"
                                label="Workspace slug"
                            >
                                <Input
                                    id="onboarding-side-split-workspace-slug"
                                    value={workspaceSlug}
                                    onChange={(event) =>
                                        setWorkspaceSlug(event.target.value)
                                    }
                                />
                            </Form.Field>
                        </Form>

                        <div className="mt-8">
                            <p className="mb-2 font-medium text-foreground">
                                Starting team size
                            </p>
                            <div className="flex flex-wrap gap-2">
                                {teamSizeOptions.map((option) => {
                                    const selected = teamSize === option

                                    return (
                                        <div
                                            className="relative inline-flex"
                                            key={option}
                                        >
                                            <Button
                                                aria-pressed={selected}
                                                className={focusRingClass}
                                                type="button"
                                                variant={selected ? 'subtle' : 'default'}
                                                onKeyDown={(event) =>
                                                    handleKeyboardAction(event, () =>
                                                        setTeamSize(option),
                                                    )
                                                }
                                                icon={
                                                    selected ? (
                                                        <PiCheckCircleFill
                                                            aria-hidden="true"
                                                            className="text-lg text-primary"
                                                        />
                                                    ) : undefined
                                                }
                                                iconAlignment="end"
                                                onClick={() => setTeamSize(option)}
                                            >
                                                {option}
                                            </Button>
                                        </div>
                                    )
                                })}
                            </div>
                        </div>
                    </div>
                )
            case 4:
                return (
                    <div className="mt-8">
                        <p className="mb-2 font-medium text-foreground">Starting view</p>
                        <div
                            aria-label="Workspace starting view"
                            className="grid grid-cols-1 gap-4"
                            role="radiogroup"
                        >
                            {workspaceViewOptions.map((option, index) => {
                                const selected = workspaceView === option.value
                                const ViewIcon = option.icon

                                return (
                                    <div
                                        aria-checked={selected}
                                        className={classNames(
                                            'flex items-center justify-between border rounded-card p-2 h-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
                                            selected
                                                ? 'ring border-primary'
                                                : 'hover:border-primary',
                                        )}
                                        key={option.value}
                                        role="radio"
                                        tabIndex={selected ? 0 : -1}
                                        onClick={() => setWorkspaceView(option.value)}
                                        onKeyDown={(event) =>
                                            handleViewKeyDown(event, index)
                                        }
                                    >
                                        <div className="flex items-center gap-2">
                                            <IconFrame size={28} variant="muted">
                                                <ViewIcon
                                                    aria-hidden="true"
                                                    className={classNames(
                                                        'text-xl',
                                                        selected && 'text-primary',
                                                    )}
                                                />
                                            </IconFrame>
                                            <p
                                                className={classNames(
                                                    'font-semibold',
                                                    selected
                                                        ? 'text-primary'
                                                        : 'text-card-foreground',
                                                )}
                                            >
                                                {option.label}
                                            </p>
                                        </div>
                                        {selected ? (
                                            <span
                                                aria-hidden="true"
                                                className="h-4 w-4 flex items-center justify-center rounded-full bg-primary text-primary-foreground"
                                            >
                                                <PiCheck />
                                            </span>
                                        ) : null}
                                    </div>
                                )
                            })}
                        </div>
                    </div>
                )
            default:
                return null
        }
    }

    const renderFooter = () => {
        if (isComplete) {
            return null
        }

        return (
            <footer className="px-8 pb-8 sm:px-12">
                <div className="flex items-center justify-between mx-auto w-full max-w-[380px] xl:max-w-[450px]">
                    {
                        currentStep > 0 ? (
                            <Button
                                icon={<PiArrowLeft aria-hidden="true" />}
                                iconAlignment="start"
                                type="button"
                                variant="ghost"
                                onKeyDown={(event) =>
                                    handleKeyboardAction(event, handlePrevious)
                                }
                                onClick={handlePrevious}
                            >
                                Previous
                            </Button>
                        )
                        : (
                            <div />
                        )
                    }
                    <Button
                        className={focusRingClass}
                        icon={<PiArrowRight aria-hidden="true" />}
                        iconAlignment="end"
                        type="button"
                        block={currentStep === 0}
                        variant="solid"
                        onKeyDown={(event) =>
                            handleKeyboardAction(event, handleNext)
                        }
                        onClick={handleNext}
                    >
                        Next step
                    </Button>
                </div>
            </footer>
        )
    }

    const renderLeftColumn = () => (
        <section
            aria-labelledby="onboarding-side-split-title"
            className="w-full"
        >
            {isComplete ? (
                <main className="flex flex-col items-center text-center">
                    <div className="rounded-full bg-palette-emerald-soft p-4">
                        <IconFrame
                            className="rounded-full bg-palette-emerald text-success/30 drop-shadow-[0_4px_6px_currentColor]"
                            size={64}
                            variant="muted"
                        >
                            <PiCheckBold
                                aria-hidden="true"
                                className="text-3xl text-primary-foreground"
                            />
                        </IconFrame>
                    </div>
                    <h4
                        className="mt-8 text-xl font-semibold text-foreground"
                        id="onboarding-side-split-title"
                    >
                        Ready to go!
                    </h4>
                    <p className="mt-1 text-muted-foreground">
                        The workspace can now be opened for the first task.
                    </p>
                    <div className="mt-8 flex w-full flex-col items-center gap-4">
                        <Button
                            block
                            className={focusRingClass}
                            type="button"
                            variant="solid"
                        >
                            Open workspace
                        </Button>
                        <Button
                            block
                            className={focusRingClass}
                            type="button"
                            variant="ghost"
                            onClick={() => setCurrentStep(totalSteps - 1)}
                        >
                            Review selections
                        </Button>
                    </div>
                </main>
            ) : (
                <main aria-live="polite">
                    <h4
                        className="text-xl font-semibold leading-tight text-foreground"
                        id="onboarding-side-split-title"
                    >
                        {activeStep.title}
                    </h4>
                    <p className="mt-1 text-muted-foreground">
                        {activeStep.subtitle}
                    </p>
                    {renderStepContent()}
                </main>
            )}
        </section>
    )

    return (
        <AuthShellSplit
            className="h-screen"
            header={
                <div className="flex items-center justify-between gap-4 p-2">
                    <div>
                        <img
                            alt=""
                            className="h-7 w-auto dark:hidden"
                            src={logoSrc}
                        />
                        <img
                            alt="NateUI"
                            className="hidden h-7 w-auto dark:block"
                            src={logoWhiteSrc}
                        />
                    </div>
                    <div className="flex min-w-0 items-center gap-2 px-4">
                        <div
                            aria-label={
                                isComplete
                                    ? 'Onboarding complete'
                                    : `Step ${currentStep + 1} of ${totalSteps}`
                            }
                            aria-valuemax={100}
                            aria-valuemin={0}
                            aria-valuenow={progressPercent}
                            className="w-24"
                            role="progressbar"
                        >
                            <Progress
                                percent={progressPercent}
                                showInfo={false}
                            />
                        </div>
                        <span className="flex items-center gap-2 whitespace-nowrap font-medium text-muted-foreground">
                            {isComplete ? 'Setup complete' : `${currentStep + 1} of ${totalSteps}`}
                        </span>
                        {isComplete ? (
                            <PiCheckCircleFill
                                aria-hidden="true"
                                className="text-base text-success"
                            />
                        ) : null}
                    </div>
                </div>
            }
            sideContent={
                <aside
                    className="relative hidden flex-col overflow-hidden rounded-card bg-background bg-cover bg-center bg-no-repeat lg:flex"
                    style={{ backgroundImage: `url(${backgroundSrc})` }}
                >
                    <div
                        aria-hidden="true"
                        className="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-background/90"
                    />
                    <div className="relative min-h-0 flex-1 overflow-hidden mask-b-from-75%">
                        <div
                            aria-hidden="true"
                            className="absolute left-24 top-24 h-full w-full rounded-[30px] bg-inverse/10 p-2 xl:left-12 xl:top-12 xl:rounded-[40px]"
                        >
                            <img
                                alt=""
                                className="h-full w-full rounded-[24px] object-cover object-left-top"
                                src={mediaSrc}
                            />
                        </div>
                    </div>
                </aside>
            }
            footer={renderFooter()}
        >
            <Container
                asElement="div"
                className="w-full"
                size="lg"
            >
                {renderLeftColumn()}
            </Container>
        </AuthShellSplit>
    )
}

Onboarding 04

Preview
npx nateui@latest add OnboardingSetupDialog
Dark
import { useState } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
import classNames from '@/utils/classNames'
import Avatar from '@/components/ui/Avatar'
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
import Checkbox from '@/components/ui/Checkbox'
import Dialog from '@/components/ui/Dialog'
import Form from '@/components/ui/Form'
import IconFrame from '@/components/composites/IconFrame'
import Input from '@/components/ui/Input'
import Progress from '@/components/ui/Progress'
import Radio from '@/components/ui/Radio'
import Select from '@/components/ui/Select'
import Upload from '@/components/ui/Upload'
import {
    PiArrowLeft,
    PiArrowRight,
    PiBell,
    PiCalendarBlank,
    PiChartBar,
    PiChartLineUp,
    PiChatCircleText,
    PiCheck,
    PiCheckBold,
    PiCheckCircleFill,
    PiCheckSquare,
    PiFileText,
    PiKanban,
    PiLightbulb,
    PiMapTrifold,
    PiNote,
    PiRows,
    PiSquaresFour,
    PiUploadSimple,
    PiUser,
    PiUsersThree,
} from 'react-icons/pi'

const totalSteps = 5
const dialogTitleId = 'onboarding-dialog-title'

const stepDetails = [
    {
        title: 'Add profile details',
        subtitle: 'Choose the details that will identify you in this workspace.',
    },
    {
        title: 'Choose your work focus',
        subtitle: 'Select the activity that best matches your starting view.',
    },
    {
        title: 'Tune your starting topics',
        subtitle: 'Optional: choose subjects to show in your first workspace view.',
    },
    {
        title: 'Name your workspace',
        subtitle: 'Choose a name and URL for the shared space.',
    },
    {
        title: 'Pick a workspace view',
        subtitle: 'Choose the layout you want to open first.',
    },
]

const timezoneOptions = [
    { label: 'UTC-08:00 Pacific', value: 'pacific' },
    { label: 'UTC-05:00 Eastern', value: 'eastern' },
    { label: 'UTC+01:00 Central European', value: 'central-european' },
    { label: 'UTC+08:00 Singapore', value: 'singapore' },
]

const workFocusOptions = [
    { label: 'Planning', value: 'planning' },
    { label: 'Building', value: 'building' },
    { label: 'Designing', value: 'designing' },
    { label: 'Research', value: 'research' },
    { label: 'Operations', value: 'operations' },
    { label: 'Support', value: 'support' },
]

const interestOptions = [
    { label: 'Projects', value: 'projects', icon: PiKanban },
    { label: 'Notes', value: 'notes', icon: PiNote },
    { label: 'Tasks', value: 'tasks', icon: PiCheckSquare },
    { label: 'Files', value: 'files', icon: PiFileText },
    { label: 'Calendars', value: 'calendars', icon: PiCalendarBlank },
    { label: 'People', value: 'people', icon: PiUsersThree },
    { label: 'Reports', value: 'reports', icon: PiChartBar },
    { label: 'Roadmaps', value: 'roadmaps', icon: PiMapTrifold },
    { label: 'Ideas', value: 'ideas', icon: PiLightbulb },
    { label: 'Reviews', value: 'reviews', icon: PiChatCircleText },
    { label: 'Updates', value: 'updates', icon: PiBell },
    { label: 'Templates', value: 'templates', icon: PiSquaresFour },
    { label: 'Metrics', value: 'metrics', icon: PiChartLineUp },
]

const teamSizeOptions = [
    'Solo',
    '2-8',
    '9-24',
    '25-75',
    '76-250',
    '251-750',
    '751-2,500',
    '2,500+',
]

const workspaceViewOptions = [
    {
        label: 'Overview',
        value: 'overview',
        description: 'Recent activity and open work in one summary.',
        icon: PiSquaresFour,
    },
    {
        label: 'Board',
        value: 'board',
        description: 'A column for each stage, moved by drag.',
        icon: PiKanban,
    },
    {
        label: 'Timeline',
        value: 'timeline',
        description: 'Work plotted across dates and owners.',
        icon: PiChartLineUp,
    },
    {
        label: 'Table',
        value: 'table',
        description: 'Rows and columns with filters and sorting.',
        icon: PiRows,
    },
]

export default function OnboardingSetupDialog() {
    const [isOpen, setIsOpen] = useState(true)
    const [currentStep, setCurrentStep] = useState(0)
    const [displayName, setDisplayName] = useState('Jordan Lee')
    const [roleLabel, setRoleLabel] = useState('Operations lead')
    const [timezone, setTimezone] = useState('singapore')
    const [updatesEnabled, setUpdatesEnabled] = useState(true)
    const [workFocus, setWorkFocus] = useState('building')
    const [interests, setInterests] = useState(['projects', 'notes', 'tasks'])
    const [workspaceName, setWorkspaceName] = useState('Northstar')
    const [workspaceSlug, setWorkspaceSlug] = useState('northstar')
    const [teamSize, setTeamSize] = useState('9-24')
    const [workspaceView, setWorkspaceView] = useState('overview')

    const isComplete = currentStep === totalSteps
    const activeStep = stepDetails[Math.min(currentStep, totalSteps - 1)]
    const progressPercent = isComplete
        ? 100
        : ((currentStep + 1) / totalSteps) * 100

    const handleNext = () => {
        setCurrentStep((step) => Math.min(step + 1, totalSteps))
    }

    const handlePrevious = () => {
        setCurrentStep((step) => Math.max(step - 1, 0))
    }

    const handleSkip = () => {
        handleNext()
    }

    const handleOpen = () => {
        setCurrentStep(0)
        setIsOpen(true)
    }

    const handleClose = () => {
        setIsOpen(false)
    }

    const handleKeyboardAction = (
        event: ReactKeyboardEvent<HTMLElement>,
        action: () => void,
    ) => {
        if (event.key === 'Enter' || event.key === ' ') {
            event.preventDefault()
            action()
        }
    }

    const toggleInterest = (value: string) => {
        setInterests((selected) =>
            selected.includes(value)
                ? selected.filter((item) => item !== value)
                : [...selected, value],
        )
    }

    const handleViewKeyDown = (
        event: ReactKeyboardEvent<HTMLElement>,
        index: number,
    ) => {
        const { key } = event

        if (key === 'Enter' || key === ' ') {
            event.preventDefault()
            setWorkspaceView(workspaceViewOptions[index].value)
            return
        }

        const forward = key === 'ArrowRight' || key === 'ArrowDown'
        const backward = key === 'ArrowLeft' || key === 'ArrowUp'

        if (!forward && !backward) {
            return
        }

        event.preventDefault()

        const optionCount = workspaceViewOptions.length
        const nextIndex = forward
            ? (index + 1) % optionCount
            : (index - 1 + optionCount) % optionCount

        setWorkspaceView(workspaceViewOptions[nextIndex].value)

        const nextCard = event.currentTarget.parentElement?.children[nextIndex]

        if (nextCard instanceof HTMLElement) {
            nextCard.focus()
        }
    }

    const renderStepContent = () => {
        switch (currentStep) {
            case 0:
                return (
                    <div className="mt-8">
                        <Upload
                            accept="image/png,image/jpeg"
                            className="w-full"
                            showList={false}
                            uploadLimit={1}
                        >
                            <div className="mb-4 flex items-center gap-4">
                                <Avatar
                                    aria-hidden="true"
                                    icon={<PiUser />}
                                    size="lg"
                                />
                                <div className="flex min-w-0 flex-col items-start gap-2">
                                    <Button
                                        icon={<PiUploadSimple aria-hidden="true" />}
                                        size="sm"
                                        type="button"
                                    >
                                        Add image
                                    </Button>
                                </div>
                            </div>
                        </Upload>

                        <Form
                            className="mt-4"
                            onSubmit={(event) => event.preventDefault()}
                        >
                            <Form.Field
                                asterisk
                                htmlFor="onboarding-dialog-display-name"
                                label="Display name"
                            >
                                <Input
                                    id="onboarding-dialog-display-name"
                                    value={displayName}
                                    onChange={(event) =>
                                        setDisplayName(event.target.value)
                                    }
                                />
                            </Form.Field>
                            <Form.Field
                                htmlFor="onboarding-dialog-role-label"
                                label="Role label"
                            >
                                <Input
                                    id="onboarding-dialog-role-label"
                                    value={roleLabel}
                                    onChange={(event) =>
                                        setRoleLabel(event.target.value)
                                    }
                                />
                            </Form.Field>
                            <Form.Field
                                htmlFor="onboarding-dialog-timezone"
                                label="Time zone"
                            >
                                <Select
                                    inputId="onboarding-dialog-timezone"
                                    options={timezoneOptions}
                                    placeholder="Choose a time zone"
                                    value={timezoneOptions.find(
                                        (option) => option.value === timezone,
                                    )}
                                    onChange={(option) =>
                                        setTimezone(option.value as string)
                                    }
                                />
                            </Form.Field>
                            <Checkbox
                                checked={updatesEnabled}
                                onChange={(checked) =>
                                    setUpdatesEnabled(checked)
                                }
                            >
                                Account notices by email
                            </Checkbox>
                        </Form>
                    </div>
                )
            case 1:
                return (
                    <div className="mt-8 flex flex-col gap-4">
                        <p className="font-medium text-foreground">
                            Pick one activity
                        </p>
                        <Radio.Group
                            className="w-full gap-2"
                            name="onboarding-dialog-work-focus"
                            value={workFocus}
                            vertical
                            onChange={(value) => setWorkFocus(value as string)}
                        >
                            {workFocusOptions.map((option) => (
                                <div
                                    className={classNames(
                                        'w-full',
                                        workFocus !== option.value &&
                                            'hover:bg-accent',
                                    )}
                                    key={option.value}
                                >
                                    <Radio
                                        className="w-full rounded-control border px-4 py-2"
                                        value={option.value}
                                        onKeyDown={(event) =>
                                            handleKeyboardAction(event, () =>
                                                setWorkFocus(option.value),
                                            )
                                        }
                                    >
                                        {option.label}
                                    </Radio>
                                </div>
                            ))}
                        </Radio.Group>
                    </div>
                )
            case 2:
                return (
                    <div className="mt-8 flex flex-wrap gap-2">
                        {interestOptions.map((interest) => {
                            const selected = interests.includes(interest.value)

                            return (
                                <div
                                    className="relative inline-flex"
                                    key={interest.value}
                                >
                                    <Button
                                        aria-pressed={selected}
                                        icon={
                                            selected ? (
                                                <PiCheckCircleFill
                                                    aria-hidden="true"
                                                    className="text-xl text-primary"
                                                />
                                            ) : undefined
                                        }
                                        iconAlignment="end"
                                        type="button"
                                        variant={
                                            selected ? 'subtle' : 'default'
                                        }
                                        onKeyDown={(event) =>
                                            handleKeyboardAction(event, () =>
                                                toggleInterest(interest.value),
                                            )
                                        }
                                        onClick={() =>
                                            toggleInterest(interest.value)
                                        }
                                    >
                                        {interest.label}
                                    </Button>
                                </div>
                            )
                        })}
                    </div>
                )
            case 3:
                return (
                    <div className="mt-8">
                        <Form
                            onSubmit={(event) => event.preventDefault()}
                        >
                            <Form.Field
                                asterisk
                                htmlFor="onboarding-dialog-workspace-name"
                                label="Workspace name"
                            >
                                <Input
                                    id="onboarding-dialog-workspace-name"
                                    value={workspaceName}
                                    onChange={(event) =>
                                        setWorkspaceName(event.target.value)
                                    }
                                />
                            </Form.Field>
                            <Form.Field
                                asterisk
                                htmlFor="onboarding-dialog-workspace-slug"
                                label="Workspace slug"
                            >
                                <Input
                                    id="onboarding-dialog-workspace-slug"
                                    value={workspaceSlug}
                                    onChange={(event) =>
                                        setWorkspaceSlug(event.target.value)
                                    }
                                />
                            </Form.Field>
                        </Form>

                        <div className="mt-8">
                            <p className="mb-2 font-medium text-foreground">
                                Starting team size
                            </p>
                            <div className="flex flex-wrap gap-2">
                                {teamSizeOptions.map((option) => {
                                    const selected = teamSize === option

                                    return (
                                        <div
                                            className="relative inline-flex"
                                            key={option}
                                        >
                                            <Button
                                                aria-pressed={selected}
                                                icon={
                                                    selected ? (
                                                        <PiCheckCircleFill
                                                            aria-hidden="true"
                                                            className="text-lg text-primary"
                                                        />
                                                    ) : undefined
                                                }
                                                iconAlignment="end"
                                                type="button"
                                                variant={
                                                    selected
                                                        ? 'subtle'
                                                        : 'default'
                                                }
                                                onKeyDown={(event) =>
                                                    handleKeyboardAction(event, () =>
                                                        setTeamSize(option),
                                                    )
                                                }
                                                onClick={() =>
                                                    setTeamSize(option)
                                                }
                                            >
                                                {option}
                                            </Button>
                                        </div>
                                    )
                                })}
                            </div>
                        </div>
                    </div>
                )
            case 4:
                return (
                    <div className="mt-8">
                        <p className="mb-2 font-medium text-foreground">Starting view</p>
                        <div
                            aria-label="Workspace starting view"
                            className="grid grid-cols-1 gap-4 sm:grid-cols-2"
                            role="radiogroup"
                        >
                            {workspaceViewOptions.map((option, index) => {
                                const selected = workspaceView === option.value
                                const ViewIcon = option.icon

                                return (
                                    <Card
                                        aria-checked={selected}
                                        className={classNames(
                                            'relative h-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
                                            selected
                                                ? 'border-primary ring'
                                                : 'hover:border-primary',
                                        )}
                                        key={option.value}
                                        role="radio"
                                        tabIndex={selected ? 0 : -1}
                                        bodyClass="flex h-full flex-col gap-8 p-4"
                                        onClick={() =>
                                            setWorkspaceView(option.value)
                                        }
                                        onKeyDown={(event) =>
                                            handleViewKeyDown(event, index)
                                        }
                                    >
                                        <IconFrame size={40} variant="muted">
                                            <ViewIcon
                                                aria-hidden="true"
                                                className={classNames(
                                                    'text-xl',
                                                    selected && 'text-primary',
                                                )}
                                            />
                                        </IconFrame>
                                        <div className="space-y-1">
                                            <p
                                                className={classNames(
                                                    'font-semibold',
                                                    selected
                                                        ? 'text-primary'
                                                        : 'text-card-foreground',
                                                )}
                                            >
                                                {option.label}
                                            </p>
                                            <p className="pe-8 text-muted-foreground">
                                                {option.description}
                                            </p>
                                        </div>
                                        {selected ? (
                                            <span
                                                aria-hidden="true"
                                                className="absolute bottom-4 end-4 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground"
                                            >
                                                <PiCheck className="text-sm" />
                                            </span>
                                        ) : null}
                                    </Card>
                                )
                            })}
                        </div>
                    </div>
                )
            default:
                return null
        }
    }

    const renderDialogHeader = () => (
        <header className="flex items-start justify-between gap-4 border-b px-6 py-4 pe-12">
            <div className="min-w-0">
                <h2
                    className="text-lg font-semibold text-foreground"
                    id={dialogTitleId}
                >
                    Workspace setup
                </h2>
                <p className="mt-1 text-xs text-muted-foreground">
                    {isComplete ? 'Complete' : `Step ${currentStep + 1} of ${totalSteps}`}
                </p>
            </div>
            <div
                aria-label="Onboarding progress"
                aria-valuemax={100}
                aria-valuemin={0}
                aria-valuenow={progressPercent}
                className="w-24 shrink-0 pt-1"
                role="progressbar"
            >
                <Progress
                    percent={progressPercent}
                    showInfo={false}
                    size="sm"
                />
            </div>
        </header>
    )

    const renderCompletion = () => (
        <div className="mx-auto flex w-full max-w-sm flex-col justify-center items-center text-center h-full">
            <div className="rounded-full bg-palette-emerald-soft p-4">
                <IconFrame
                    className="rounded-full bg-palette-emerald text-success/30 drop-shadow-[0_4px_6px_currentColor]"
                    size={64}
                    variant="muted"
                >
                    <PiCheckBold
                        aria-hidden="true"
                        className="text-3xl text-primary-foreground"
                    />
                </IconFrame>
            </div>
            <h3 className="mt-8 text-2xl font-semibold text-foreground">
                Ready to go!
            </h3>
            <p className="mt-1 text-muted-foreground">
                The workspace can now be opened for the first task.
            </p>
            <div className="mt-8 flex w-full flex-col items-center gap-4">
                <Button
                    block
                    type="button"
                    variant="solid"
                    onClick={handleClose}
                >
                    Open workspace
                </Button>
                <Button
                    block
                    type="button"
                    variant="ghost"
                    onClick={() => setCurrentStep(totalSteps - 1)}
                >
                    Review selections
                </Button>
            </div>
        </div>
    )

    const renderDialogFooter = () => {
        if (isComplete) {
            return null
        }

        return (
            <footer className="flex flex-wrap items-center justify-between gap-4 border-t px-6 py-4">
                {currentStep === 0 ? (
                    <span aria-hidden="true" />
                ) : (
                    <Button
                        icon={<PiArrowLeft aria-hidden="true" />}
                        iconAlignment="start"
                        type="button"
                        variant="ghost"
                        onKeyDown={(event) =>
                            handleKeyboardAction(event, handlePrevious)
                        }
                        onClick={handlePrevious}
                    >
                        Previous
                    </Button>
                )}
                <div className="flex flex-wrap items-center gap-2">
                    {currentStep > 0 && (
                        <Button
                            type="button"
                            variant="ghost"
                            onKeyDown={(event) =>
                                handleKeyboardAction(event, handleSkip)
                            }
                            onClick={handleSkip}
                        >
                            Skip
                        </Button>
                    )}
                    <Button
                        icon={<PiArrowRight aria-hidden="true" />}
                        iconAlignment="end"
                        type="button"
                        variant="solid"
                        onKeyDown={(event) =>
                            handleKeyboardAction(event, handleNext)
                        }
                        onClick={handleNext}
                    >
                        Next step
                    </Button>
                </div>
            </footer>
        )
    }

    return (
        <main className="relative flex min-h-screen items-center justify-center bg-background p-4 sm:p-8">
            <Button
                className="relative"
                onClick={handleOpen}
            >
                Reopen setup flow
            </Button>

            <Dialog
                aria-labelledby={dialogTitleId}
                className="flex max-h-[calc(100dvh-2rem)] min-h-[34rem] flex-col overflow-hidden p-0"
                overlayClassName="bg-white/50"
                closable={false}
                isOpen={isOpen}
                lockScroll
                onClose={handleClose}
                shouldCloseOnEsc={false}
                shouldCloseOnOverlayClick={false}
                height={680}
                width={560}
            >
                <div className="min-h-0 flex-1 overflow-y-auto p-4">
                    {isComplete ? (
                        renderCompletion()
                    ) : (
                        <div aria-live="polite" className="mx-auto w-full max-w-lg">
                            <h4 className="text-xl font-semibold text-foreground">
                            {activeStep.title}
                            </h4>
                            <p className="mt-1 text-muted-foreground">
                                {activeStep.subtitle}
                            </p>
                            {renderStepContent()}
                        </div>
                    )}
                </div>
                {renderDialogFooter()}
            </Dialog>
        </main>
    )
}

Onboarding 05

Preview
npx nateui@latest add OnboardingGuideDialog
Dark
import { useCallback, useEffect, useRef, useState } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
import classNames from '@/utils/classNames'
import IconFrame from '@/components/composites/IconFrame'
import Button from '@/components/ui/Button'
import Dialog from '@/components/ui/Dialog'
import {
    PiCalendarFill ,
    PiCaretRight,
    PiMagnifyingGlass,
    PiUserCircleFill,
} from 'react-icons/pi'

const assetBase = 'https://statics.nateui.com/img'

const guidePanels = [
    {
        title: 'Welcome to NateUI',
        description: 'Take a quick look at the workspace before you begin.',
        icons: [],
        logo: `${assetBase}/logos/logo-collapsed.svg`,
        washTokens: [
            '--color-chart-1',
            '--color-chart-4',
            '--color-chart-7',
            '--color-chart-9',
        ],
    },
    {
        title: 'Set dates for upcoming work',
        description:
            'Due dates place work on the calendar and show what is scheduled next.',
        icons: [PiCalendarFill ],
        logo: null,
        washTokens: [
            '--color-chart-3',
            '--color-chart-6',
            '--color-chart-2',
            '--color-chart-8',
        ],
    },
    {
        title: 'Make ownership explicit',
        description:
            'Assign an owner to projects and tasks so responsibility is visible in the workspace.',
        icons: [PiUserCircleFill],
        logo: null,
        washTokens: [
            '--color-chart-5',
            '--color-chart-1',
            '--color-chart-8',
            '--color-chart-4',
        ],
    },
    {
        title: 'Search the whole workspace',
        description:
            'Find work by name, then narrow the results by project, owner, or status.',
        icons: [PiMagnifyingGlass],
        logo: null,
        washTokens: [
            '--color-chart-7',
            '--color-chart-3',
            '--color-chart-9',
            '--color-chart-2',
        ],
    },
]

// Entry owns the total transition. Exit is 75% of that duration, while the
// copy duration subtracts its entry delay so every moving layer finishes
// before the outgoing panel is removed.
const panelTransitionMs = 300
const panelLeaveMs = Math.round(panelTransitionMs * 0.75)
const panelCopyEnterDelayMs = 50
const panelCopyEnterMs = panelTransitionMs - panelCopyEnterDelayMs
const panelTransitionReleaseMs = 32

const dialogTitleId = 'onboarding-guide-dialog-title'
const dialogDescriptionId = 'onboarding-guide-dialog-description'
const stripePatternId = 'onboarding-guide-dialog-stripes'

const createMediaWash = (tokens: string[]) =>
    [
        `radial-gradient(ellipse at 20% 15%, color-mix(in oklab, var(${tokens[0]}) 38%, transparent) 0%, transparent 62%)`,
        `radial-gradient(ellipse at 85% 10%, color-mix(in oklab, var(${tokens[1]}) 34%, transparent) 0%, transparent 58%)`,
        `radial-gradient(ellipse at 70% 85%, color-mix(in oklab, var(${tokens[2]}) 30%, transparent) 0%, transparent 64%)`,
        `radial-gradient(ellipse at 8% 88%, color-mix(in oklab, var(${tokens[3]}) 24%, transparent) 0%, transparent 60%)`,
    ].join(', ')

const GuideWash = ({ panelIndex }: { panelIndex: number }) => {
    const panel = guidePanels[panelIndex]

    return (
        <div
            aria-hidden="true"
            className="h-full w-full opacity-90 dark:opacity-70"
            style={{ backgroundImage: createMediaWash(panel.washTokens) }}
        />
    )
}

const GuideIllustration = ({ panelIndex }: { panelIndex: number }) => {
    const panel = guidePanels[panelIndex]

    return (
        <div className="flex h-full items-center justify-center gap-8">
            {panel.logo && (
                <IconFrame
                    aria-hidden="true"
                    className="rounded-[24px] bg-card/70 backdrop-blur-md"
                    size={80}
                    variant="elavated"
                >
                    <img
                        alt=""
                        className="h-12 w-12"
                        src={panel.logo}
                    />
                </IconFrame>
            )}
            {panel.icons.map((Icon, index) => (
                <IconFrame
                    aria-hidden="true"
                    className="rounded-[24px] bg-card/70 backdrop-blur-md"
                    key={`${panelIndex}-${index}`}
                    size={80}
                    variant="elavated"
                >
                    <Icon className="text-3xl text-card-foreground" />
                </IconFrame>
            ))}
        </div>
    )
}

const GuideCopy = ({
    active,
    panelIndex,
    onAdvance,
}: {
    active: boolean
    panelIndex: number
    onAdvance: () => void
}) => {
    const panel = guidePanels[panelIndex]
    const isLastPanel = panelIndex === guidePanels.length - 1

    return (
        <div className="flex h-full min-h-0 flex-col px-8 pt-8 pb-4">
            <h2
                className="text-xl font-semibold text-foreground"
                id={active ? dialogTitleId : undefined}
            >
                {panel.title}
            </h2>
            <p
                className="mt-2 text-muted-foreground"
                id={active ? dialogDescriptionId : undefined}
            >
                {panel.description}
            </p>
            <Button
                className="mt-8 self-start"
                icon={<PiCaretRight aria-hidden="true" />}
                iconAlignment="end"
                tabIndex={active ? undefined : -1}
                type="button"
                variant="solid"
                onClick={onAdvance}
            >
                {isLastPanel ? 'Finish guide' : 'Continue'}
            </Button>
        </div>
    )
}

export default function OnboardingGuideDialog() {
    const [isOpen, setIsOpen] = useState(true)
    const [currentPanel, setCurrentPanel] = useState(0)
    const [outgoingPanel, setOutgoingPanel] = useState<number | null>(null)
    const [phase, setPhase] = useState<'idle' | 'preparing' | 'animating'>(
        'idle',
    )
    const releaseTimerRef = useRef<number | null>(null)
    const finishTimerRef = useRef<number | null>(null)

    const isLastPanel = currentPanel === guidePanels.length - 1

    const clearTransitionTimers = useCallback(() => {
        if (releaseTimerRef.current !== null) {
            window.clearTimeout(releaseTimerRef.current)
            releaseTimerRef.current = null
        }

        if (finishTimerRef.current !== null) {
            window.clearTimeout(finishTimerRef.current)
            finishTimerRef.current = null
        }
    }, [])

    useEffect(() => clearTransitionTimers, [clearTransitionTimers])

    const handleClose = () => {
        clearTransitionTimers()
        setOutgoingPanel(null)
        setPhase('idle')
        setIsOpen(false)
    }

    const handleOpen = () => {
        clearTransitionTimers()
        setCurrentPanel(0)
        setOutgoingPanel(null)
        setPhase('idle')
        setIsOpen(true)
    }

    const handlePanelChange = (panelIndex: number) => {
        if (panelIndex === currentPanel || phase !== 'idle') {
            return
        }

        clearTransitionTimers()
        setOutgoingPanel(currentPanel)
        setCurrentPanel(panelIndex)
        setPhase('preparing')

        // One frame at the entry offset, then release so the transition runs.
        // A timer rather than requestAnimationFrame, which does not fire while
        // the tab is hidden and would strand the panel faded out.
        releaseTimerRef.current = window.setTimeout(() => {
            setPhase('animating')
            releaseTimerRef.current = null
        }, panelTransitionReleaseMs)

        finishTimerRef.current = window.setTimeout(() => {
            setOutgoingPanel(null)
            setPhase('idle')
            finishTimerRef.current = null
        }, panelTransitionReleaseMs + panelTransitionMs)
    }

    const handleAdvance = () => {
        if (isLastPanel) {
            handleClose()
            return
        }

        handlePanelChange(currentPanel + 1)
    }

    const handlePanelKeyDown = (
        event: ReactKeyboardEvent<HTMLButtonElement>,
        panelIndex: number,
    ) => {
        if (event.key === 'Enter' || event.key === ' ') {
            event.preventDefault()
            handlePanelChange(panelIndex)
        }
    }

    return (
        <main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4 sm:p-8">
            <div
                aria-hidden="true"
                className="absolute inset-4 overflow-hidden rounded-card border-2 border-dashed sm:inset-8"
            >
                <svg className="h-full w-full text-muted" fill="none">
                    <defs>
                        <pattern
                            height="8"
                            id={stripePatternId}
                            patternTransform="rotate(45)"
                            patternUnits="userSpaceOnUse"
                            width="8"
                        >
                            <line
                                stroke="currentColor"
                                strokeWidth="2"
                                x1="0"
                                x2="0"
                                y1="0"
                                y2="8"
                            />
                        </pattern>
                    </defs>
                    <rect
                        fill={`url(#${stripePatternId})`}
                        height="100%"
                        width="100%"
                    />
                </svg>
            </div>

            <Button className="relative" type="button" onClick={handleOpen}>
                Open feature guide
            </Button>

            <Dialog
                aria-describedby={dialogDescriptionId}
                aria-labelledby={dialogTitleId}
                className="flex max-h-dvh flex-col overflow-hidden p-0"
                closable
                height={500}
                isOpen={isOpen}
                lockScroll
                shouldCloseOnEsc
                shouldCloseOnOverlayClick
                width={400}
                onClose={handleClose}
            >
                <div className="flex h-full min-h-0 flex-col">
                    <div className="flex min-h-0 flex-1 flex-col">
                        <section
                            aria-hidden="true"
                            className="relative h-56 shrink-0 overflow-hidden bg-card"
                        >
                            {outgoingPanel !== null && (
                                <>
                                    <div
                                        className={classNames(
                                            'pointer-events-none absolute inset-0 transition-opacity ease-in-out motion-reduce:transition-none',
                                            phase === 'animating'
                                                ? 'opacity-0'
                                                : 'opacity-100 transition-none',
                                        )}
                                        style={{
                                            transitionDuration: `${panelTransitionMs}ms`,
                                        }}
                                    >
                                        <GuideWash panelIndex={outgoingPanel} />
                                    </div>
                                    <div
                                        className={classNames(
                                            'pointer-events-none absolute inset-0 transition-[opacity,translate] ease-in motion-reduce:transition-none',
                                            phase === 'animating'
                                                ? '-translate-y-6 opacity-0'
                                                : 'translate-y-0 opacity-100 transition-none',
                                        )}
                                        style={{
                                            transitionDuration: `${panelLeaveMs}ms`,
                                        }}
                                    >
                                        <GuideIllustration
                                            panelIndex={outgoingPanel}
                                        />
                                    </div>
                                </>
                            )}
                            <div
                                className={classNames(
                                    'absolute inset-0 transition-opacity ease-in-out motion-reduce:transition-none',
                                    phase === 'preparing'
                                        ? 'opacity-0 transition-none'
                                        : 'opacity-100',
                                )}
                                style={{
                                    transitionDuration: `${panelTransitionMs}ms`,
                                }}
                            >
                                <GuideWash panelIndex={currentPanel} />
                            </div>
                            <div
                                className={classNames(
                                    'relative h-full transition-[opacity,translate] ease-out motion-reduce:transition-none',
                                    phase === 'preparing'
                                        ? 'translate-y-6 opacity-0 transition-none'
                                        : 'translate-y-0 opacity-100',
                                )}
                                style={{
                                    transitionDuration: `${panelTransitionMs}ms`,
                                }}
                            >
                                <GuideIllustration panelIndex={currentPanel} />
                            </div>
                        </section>
                        <div className="relative min-h-0 flex-1 overflow-hidden">
                            {outgoingPanel !== null && (
                                <div
                                    aria-hidden="true"
                                    className={classNames(
                                        'pointer-events-none absolute inset-0 transition-[opacity,translate] ease-in motion-reduce:transition-none',
                                        phase === 'animating'
                                            ? '-translate-x-4 opacity-0'
                                            : 'translate-x-0 opacity-100 transition-none',
                                    )}
                                    inert
                                    style={{
                                        transitionDuration: `${panelLeaveMs}ms`,
                                    }}
                                >
                                    <GuideCopy
                                        active={false}
                                        panelIndex={outgoingPanel}
                                        onAdvance={handleAdvance}
                                    />
                                </div>
                            )}
                            <div
                                className={classNames(
                                    'relative h-full transition-[opacity,translate] ease-out motion-reduce:transition-none',
                                    phase === 'preparing'
                                        ? 'translate-x-4 opacity-0 transition-none'
                                        : 'translate-x-0 opacity-100',
                                )}
                                style={{
                                    transitionDelay:
                                        phase === 'animating'
                                            ? `${panelCopyEnterDelayMs}ms`
                                            : '0ms',
                                    transitionDuration: `${panelCopyEnterMs}ms`,
                                }}
                            >
                                <GuideCopy
                                    active
                                    panelIndex={currentPanel}
                                    onAdvance={handleAdvance}
                                />
                            </div>
                        </div>
                    </div>

                    <nav
                        aria-label="Guide panels"
                        className="flex shrink-0 justify-center px-8 pb-8"
                    >
                        <div className="flex items-center" role="group">
                            {guidePanels.map((panel, panelIndex) => {
                                const active = currentPanel === panelIndex

                                return (
                                    <button
                                        aria-current={active ? 'step' : undefined}
                                        aria-label={`Go to panel ${panelIndex + 1}: ${panel.title}`}
                                        className={classNames(
                                            'flex h-4 w-8 items-center justify-center rounded-control focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
                                        )}
                                        key={panel.title}
                                        type="button"
                                        onKeyDown={(event) =>
                                            handlePanelKeyDown(event, panelIndex)
                                        }
                                        onClick={() =>
                                            handlePanelChange(panelIndex)
                                        }
                                    >
                                        <span
                                            aria-hidden="true"
                                            className="relative block h-1.5 w-full"
                                        >
                                            <span
                                                className={classNames(
                                                    'absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-muted-foreground/30 transition-opacity duration-300 ease-out motion-reduce:transition-none',
                                                    active
                                                        ? 'opacity-0'
                                                        : 'opacity-100',
                                                )}
                                            />
                                            <span
                                                className={classNames(
                                                    'absolute inset-0 origin-center rounded-full bg-primary transition-[opacity,scale] duration-300 ease-out motion-reduce:transition-none',
                                                    active
                                                        ? 'scale-x-100 opacity-100'
                                                        : 'scale-x-25 opacity-0',
                                                )}
                                            />
                                        </span>
                                    </button>
                                )
                            })}
                        </div>
                    </nav>
                </div>
            </Dialog>
        </main>
    )
}