Address List
Installation
Add the following Soul components
The address-list-section component uses the badge, button, dynamic-form, spinner and toaster components. Make sure you have added them to your project.
Install the following dependencies
npm install zod @conform-to/react @conform-to/zod
Copy and paste the following code into your project
sections/address-list-section/index.tsx
'use client';import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react';import { getZodConstraint, parseWithZod } from '@conform-to/zod';import { ComponentProps, ReactNode, startTransition, useActionState, useEffect, useOptimistic, useState,} from 'react';import { useFormStatus } from 'react-dom';import { z } from 'zod';import { Badge } from '@/vibes/soul/primitives/badge';import { Button } from '@/vibes/soul/primitives/button';import { DynamicForm } from '@/vibes/soul/form/dynamic-form';import { Field, FieldGroup } from '@/vibes/soul/form/dynamic-form/schema';import { Spinner } from '@/vibes/soul/primitives/spinner';import { toast } from '@/vibes/soul/primitives/toaster';import { schema } from './schema';export type Address = z.infer<typeof schema>;export interface DefaultAddressConfiguration { id: string | null;}type Action<S, P> = (state: Awaited<S>, payload: P) => S | Promise<S>;interface State<A extends Address, F extends Field> { addresses: A[]; defaultAddress?: DefaultAddressConfiguration; lastResult: SubmissionResult | null; fields: Array<F | FieldGroup<F>>;}export interface AddressListSectionProps<A extends Address, F extends Field> { title?: string; addresses: A[]; fields: Array<F | FieldGroup<F>>; minimumAddressCount?: number; defaultAddress?: DefaultAddressConfiguration; addressAction: Action<State<A, F>, FormData>; editLabel?: string; deleteLabel?: string; updateLabel?: string; createLabel?: string; showAddFormLabel?: string; setDefaultLabel?: string; cancelLabel?: string;}/** * This component supports various CSS variables for theming. Here's a comprehensive list, along * with their default values: * * ```css * :root { * --address-list-section-border: hsl(var(--contrast-200)); * --address-list-section-title-font-family: var(--font-family-heading); * --address-list-section-content-font-family: var(--font-family-body); * --address-list-section-title: hsl(var(--foreground)); * --address-list-section-name: hsl(var(--foreground)); * --address-list-section-info: hsl(var(--contrast-500)); * } * ``` */export function AddressListSection<A extends Address, F extends Field>({ title = 'Addresses', addresses, fields, minimumAddressCount = 1, defaultAddress, addressAction, editLabel = 'Edit', deleteLabel = 'Delete', updateLabel = 'Update', createLabel = 'Create', cancelLabel = 'Cancel', showAddFormLabel = 'Add address', setDefaultLabel = 'Set as default',}: AddressListSectionProps<A, F>) { const [state, formAction] = useActionState(addressAction, { addresses, defaultAddress, lastResult: null, fields, }); const [optimisticState, setOptimisticState] = useOptimistic<State<Address, F>, FormData>( state, (prevState, formData) => { const intent = formData.get('intent'); const submission = parseWithZod(formData, { schema }); if (submission.status !== 'success') return prevState; switch (intent) { case 'create': { const nextAddress = submission.value; return { ...prevState, addresses: [...prevState.addresses, nextAddress], }; } case 'update': { return { ...prevState, addresses: prevState.addresses.map((a) => a.id === submission.value.id ? submission.value : a, ), }; } case 'delete': { return { ...prevState, addresses: prevState.addresses.filter((a) => a.id !== submission.value.id), }; } case 'setDefault': { return { ...prevState, defaultAddress: { id: submission.value.id } }; } default: return prevState; } }, ); const [activeAddressIds, setActiveAddressIds] = useState<string[]>([]); const [showNewAddressForm, setShowNewAddressForm] = useState(false); const [form] = useForm({ lastResult: state.lastResult, }); useEffect(() => { if (form.errors) { form.errors.forEach((error) => { toast.error(error); }); } }, [form.errors]); return ( <div> <div className="flex items-center justify-between"> <Title>{title}</Title> {!showNewAddressForm && ( <Button onClick={() => setShowNewAddressForm(true)} size="small"> {showAddFormLabel} </Button> )} </div> <div> {showNewAddressForm && ( <div className="border-b border-[var(--address-list-section-border,hsl(var(--contrast-200)))] pb-6 pt-5"> <div className="w-[480px] space-y-4"> <DynamicForm action={(_prevState, formData) => { setShowNewAddressForm(false); startTransition(() => { formAction(formData); setOptimisticState(formData); }); return { fields: optimisticState.fields, lastResult: optimisticState.lastResult, }; }} buttonSize="small" cancelLabel={cancelLabel} fields={optimisticState.fields.map((field) => { if ('name' in field && field.name === 'id') { return { ...field, name: 'id', defaultValue: 'new', }; } return field; })} onCancel={() => setShowNewAddressForm(false)} submitLabel={createLabel} submitName="intent" submitValue="create" /> </div> </div> )} {optimisticState.addresses.map((address) => { const addressFields = optimisticState.fields.map<F | FieldGroup<F>>((field) => { if (Array.isArray(field)) { return field.map((f) => { return { ...f, defaultValue: address[f.name] ?? '', }; }); } return { ...field, defaultValue: address[field.name] ?? '', }; }); return ( <div className="border-b border-[var(--address-list-section-border,hsl(var(--contrast-200)))] pb-6 pt-5" key={address.id} > {activeAddressIds.includes(address.id) ? ( <div className="w-[480px] space-y-4"> <DynamicForm action={(_prevState, formData) => { setActiveAddressIds((prev) => prev.filter((id) => id !== address.id)); startTransition(() => { formAction(formData); setOptimisticState(formData); }); return { fields: optimisticState.fields, lastResult: optimisticState.lastResult, }; }} buttonSize="small" cancelLabel={cancelLabel} fields={addressFields} onCancel={() => setActiveAddressIds((prev) => prev.filter((id) => id !== address.id)) } submitLabel={updateLabel} submitName="intent" submitValue="update" /> </div> ) : ( <div className="space-y-4"> <AddressPreview address={address} isDefault={ optimisticState.defaultAddress ? optimisticState.defaultAddress.id === address.id : undefined } /> <div className="flex gap-1"> <Button aria-label={`${editLabel}: ${address.firstName} ${address.lastName}`} onClick={() => setActiveAddressIds((prev) => [...prev, address.id])} size="small" variant="tertiary" > {editLabel} </Button> {optimisticState.addresses.length > minimumAddressCount && ( <AddressActionButton action={formAction} address={address} aria-label={`${deleteLabel}: ${address.firstName} ${address.lastName}`} intent="delete" onSubmit={(formData) => { startTransition(() => { formAction(formData); setOptimisticState(formData); }); }} > {deleteLabel} </AddressActionButton> )} {optimisticState.defaultAddress && optimisticState.defaultAddress.id !== address.id && ( <AddressActionButton action={formAction} address={address} aria-label={`${setDefaultLabel}: ${address.firstName} ${address.lastName}`} intent="setDefault" onSubmit={(formData) => { startTransition(() => { formAction(formData); setOptimisticState(formData); }); }} > {setDefaultLabel} </AddressActionButton> )} </div> </div> )} </div> ); })} </div> </div> );}function Title({ children }: { children: ReactNode }) { const { pending } = useFormStatus(); return ( <h1 className="font-[family-name:var(--address-list-section-title-font-family,var(--font-family-heading))] text-4xl text-[var(--address-list-section-title,hsl(var(--foreground)))]"> {children} {pending && ( <span className="ml-2"> <Spinner /> </span> )} </h1> );}function AddressPreview({ address, isDefault = false }: { address: Address; isDefault?: boolean }) { return ( <div className="flex gap-10 font-[family-name:var(--address-list-section-content-font-family,var(--font-family-body))]"> <div className="text-sm text-[var(--address-list-section-info,hsl(var(--contrast-500)))]"> <p className="font-bold text-[var(--address-list-section-name,hsl(var(--foreground)))]"> {address.firstName} {address.lastName} </p> <p>{address.company}</p> <p>{address.address1}</p> <p>{address.address2}</p> <p> {address.city}, {address.stateOrProvince} {address.postalCode} </p> <p className="mb-3">{address.countryCode}</p> <p>{address.phone}</p> </div> <div>{isDefault && <Badge>Default</Badge>}</div> </div> );}function AddressActionButton({ address, intent, action, onSubmit, ...rest}: { address: Address; intent: string; action: (formData: FormData) => void; onSubmit: (formData: FormData) => void;} & Omit<ComponentProps<'button'>, 'onSubmit'>) { const [form, fields] = useForm({ // @ts-expect-error The form requires index signature values to be of // type 'string', 'null', or 'undefined' but the zod .passthrough() method // returns the value 'unknown' for any index signature values. defaultValue: address, constraint: getZodConstraint(schema), onValidate({ formData }) { return parseWithZod(formData, { schema }); }, onSubmit(event, { submission, formData }) { event.preventDefault(); if (submission?.status !== 'success') return; onSubmit(formData); }, }); return ( <form {...getFormProps(form)} action={action}> <input {...getInputProps(fields.id, { type: 'hidden' })} key={fields.id.id} /> <input {...getInputProps(fields.firstName, { type: 'hidden' })} key={fields.firstName.id} /> <input {...getInputProps(fields.lastName, { type: 'hidden' })} key={fields.lastName.id} /> <input {...getInputProps(fields.company, { type: 'hidden' })} key={fields.company.id} /> <input {...getInputProps(fields.phone, { type: 'hidden' })} key={fields.phone.id} /> <input {...getInputProps(fields.address1, { type: 'hidden' })} key={fields.address1.id} /> <input {...getInputProps(fields.address2, { type: 'hidden' })} key={fields.address2.id} /> <input {...getInputProps(fields.city, { type: 'hidden' })} key={fields.city.id} /> <input {...getInputProps(fields.stateOrProvince, { type: 'hidden' })} key={fields.stateOrProvince.id} /> <input {...getInputProps(fields.postalCode, { type: 'hidden' })} key={fields.postalCode.id} /> <input {...getInputProps(fields.countryCode, { type: 'hidden' })} key={fields.countryCode.id} /> <Button {...rest} name="intent" size="small" type="submit" value={intent} variant="tertiary" /> </form> );}
sections/address-list-section/schema.ts
import { z } from 'zod';export const schema = z .object({ id: z.string(), firstName: z.string(), lastName: z.string(), company: z.string().optional(), address1: z.string(), address2: z.string().optional(), city: z.string(), stateOrProvince: z.string().optional(), postalCode: z.string().optional(), phone: z.string().optional(), countryCode: z.string(), }) .passthrough();
Usage
import { AddressListSection } from '@/vibes/soul/sections/address-list-section';function Usage() { return ( <AddressListSection addressAction={addressAction} addresses={addresses} defaultAddress={{ id: '1' }} fields={fields} /> );}
API Reference
See sections/address-list-section/index.tsx
for details.
CSS Variables
This component supports various CSS variables for theming. Here's a comprehensive list.
:root { --address-list-section-border: hsl(var(--contrast-200)); --address-list-section-title-font-family: var(--font-family-heading); --address-list-section-content-font-family: var(--font-family-body); --address-list-section-title: hsl(var(--foreground)); --address-list-section-name: hsl(var(--foreground)); --address-list-section-info: hsl(var(--contrast-500));}