All templates

Form dialog

Modal - Form

A modal with section navigation beside a scrolling form, with Cancel and Save in the footer. Modal, dialog, form, profile, edit details, account, text input, select, textarea.

npx @adaptavant/edscli templates form-dialog

Code

'use client';

import {
	Avatar,
	Button,
	DropdownMenu,
	DropdownMenuItem,
	DropdownMenuList,
	DropdownMenuPopover,
	DropdownMenuTrigger,
	Field,
	Fieldset,
	Heading,
	MenuItem,
	Modal,
	ModalContent,
	ModalFooter,
	ModalHeader,
	SelectMenu,
	SelectMenuItem,
	SelectMenuListbox,
	SelectMenuPopover,
	SelectMenuTrigger,
	Text,
	Textarea,
	TextInput,
} from '@adaptavant/eds-core';
import {
	AddIcon,
	BreakIcon,
	ClockIcon,
	LockClosedIcon,
	OutOfOfficeIcon,
	ProfileIcon,
} from '@adaptavant/eds-icons/icons';
import * as React from 'react';

const SECTIONS = [
	{ label: 'Profile', icon: ProfileIcon },
	{ label: 'Working hours', icon: ClockIcon },
	{ label: 'Breaks', icon: BreakIcon },
	{ label: 'Time off', icon: OutOfOfficeIcon },
	{ label: 'Login & security', icon: LockClosedIcon },
];

const COUNTRY_CODES = [
	{ label: '+1', value: '+1' },
	{ label: '+44', value: '+44' },
	{ label: '+61', value: '+61' },
	{ label: '+91', value: '+91' },
];

const ADD_OPTIONS = ['Secondary phone', 'Secondary email', 'Address'];

const INITIAL_PROFILE = {
	name: 'Alex Morgan',
	countryCode: '+1',
	phone: '415 555 0132',
	role: '',
	about: '',
};

/** Preview: the profile modal, always open. Closing only drops unsaved edits. */
export default function FormDialogTemplate() {
	return (
		<div className="h-screen bg-canvas">
			<ProfileModal onClose={() => {}} open />
		</div>
	);
}

/**
 * A modal with section navigation beside a scrolling form. Save is enabled
 * once the form has unsaved changes.
 */
export function ProfileModal({
	open,
	onClose,
}: {
	open: boolean;
	onClose: () => void;
}) {
	const [activeSection, setActiveSection] = React.useState('Profile');
	const [saved, setSaved] = React.useState(INITIAL_PROFILE);
	const [profile, setProfile] = React.useState(INITIAL_PROFILE);
	const isDirty = (Object.keys(profile) as (keyof typeof profile)[]).some(
		(key) => profile[key] !== saved[key]
	);

	const update =
		(key: keyof typeof profile) =>
		(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
			setProfile((current) => ({ ...current, [key]: event.target.value }));

	// Closing without saving drops the edits.
	const close = () => {
		setProfile(saved);
		onClose();
	};

	return (
		<Modal
			closeButtonProps={{ label: 'Close', onClick: close }}
			onClose={close}
			open={open}
			size={800}
			titleId="profile-modal-title"
		>
			<ModalHeader>
				<Heading as="h2" className="text-heading-20" id="profile-modal-title">
					Your profile
				</Heading>
			</ModalHeader>
			{/* A fixed height, so only the form column scrolls. */}
			<ModalContent className="flex h-[560px] overflow-hidden">
				<div className="flex w-[220px] shrink-0 flex-col gap-6 border-r border-solid border-tertiary pe-3">
					<div className="flex flex-col items-center gap-3 pt-2">
						<Avatar name={saved.name} size="64" />
						<Text className="text-heading-16">{saved.name}</Text>
					</div>
					<nav aria-label="Profile sections" className="flex flex-col gap-1">
						{SECTIONS.map(({ label, icon: Icon }) => (
							<MenuItem
								aria-current={label === activeSection ? 'page' : undefined}
								className="rounded-8px text-body-12"
								isHighlighted={label === activeSection}
								key={label}
								onClick={() => setActiveSection(label)}
								railStart={<Icon size="16" />}
								size="large"
							>
								{label}
							</MenuItem>
						))}
					</nav>
				</div>

				<form
					className="flex min-w-0 flex-1 flex-col gap-6 overflow-y-auto ps-6 pe-1 pb-4"
					id="profile-form"
					onSubmit={(event) => {
						event.preventDefault();
						setSaved(profile);
						onClose();
					}}
				>
					<section className="flex flex-col gap-4">
						<Heading as="h3" className="text-heading-16">
							Main details
						</Heading>
						<Field label="Full name">
							<TextInput onChange={update('name')} value={profile.name} />
						</Field>
						<Fieldset legend="Primary phone">
							<div className="flex w-full gap-2">
								<Field
									className="w-[120px] shrink-0"
									label="Country code"
									labelVisibility="hidden"
								>
									<SelectMenu popoverMatchReferenceWidth>
										<SelectMenuTrigger>{profile.countryCode}</SelectMenuTrigger>
										<SelectMenuPopover>
											<SelectMenuListbox options={COUNTRY_CODES}>
												{(option) => (
													<SelectMenuItem
														id={option.value}
														isSelected={option.value === profile.countryCode}
														onClick={() =>
															setProfile((current) => ({
																...current,
																countryCode: option.value,
															}))
														}
													>
														{option.label}
													</SelectMenuItem>
												)}
											</SelectMenuListbox>
										</SelectMenuPopover>
									</SelectMenu>
								</Field>
								<div className="min-w-0 flex-1">
									<Field label="Phone number" labelVisibility="hidden">
										<TextInput
											onChange={update('phone')}
											type="tel"
											value={profile.phone}
										/>
									</Field>
								</div>
							</div>
						</Fieldset>
						<Field isDisabled label="Primary email">
							<TextInput
								adornmentEnd={<LockClosedIcon className="me-2" size="16" />}
								type="email"
								value="alex.morgan@example.com"
							/>
						</Field>
						<Field label="Role">
							<TextInput
								onChange={update('role')}
								placeholder="Role"
								value={profile.role}
							/>
						</Field>
					</section>

					<div className="border-t border-solid border-tertiary" />

					<section className="flex flex-col gap-4">
						<Heading as="h3" className="text-heading-16">
							About
						</Heading>
						<Field label="About" labelVisibility="hidden">
							<Textarea
								onChange={update('about')}
								placeholder="About you"
								value={profile.about}
							/>
						</Field>
					</section>
				</form>
			</ModalContent>
			<ModalFooter>
				{/* Lines up with the form column: the sidebar width plus its gap. */}
				<div className="me-auto ms-[232px]">
					<DropdownMenu popoverPlacement="bottom-start">
						<DropdownMenuTrigger iconStart={AddIcon} variant="neutralTertiary">
							Add
						</DropdownMenuTrigger>
						<DropdownMenuPopover>
							<DropdownMenuList>
								{ADD_OPTIONS.map((option) => (
									<DropdownMenuItem key={option}>{option}</DropdownMenuItem>
								))}
							</DropdownMenuList>
						</DropdownMenuPopover>
					</DropdownMenu>
				</div>
				<Button onClick={close} variant="neutralTertiary">
					Cancel
				</Button>
				<Button form="profile-form" isDisabled={!isDirty} type="submit">
					Save
				</Button>
			</ModalFooter>
		</Modal>
	);
}