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-dialogPreview
Open in new tabCode
'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>
);
}