fix: envelope editor mobile view

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Catalin Pit
2026-08-12 12:43:58 +03:00
co-authored by Cursor
parent 617f8cc204
commit 01b02db201
9 changed files with 402 additions and 262 deletions
@@ -21,6 +21,12 @@ export type DocumentAttachmentsPopoverProps = {
envelopeId: string; envelopeId: string;
buttonClassName?: string; buttonClassName?: string;
buttonSize?: 'sm' | 'default'; buttonSize?: 'sm' | 'default';
/**
* Visually hide the button label below the `sm` breakpoint while keeping it
* available to screen readers.
*/
collapseLabelOnMobile?: boolean;
}; };
const ZAttachmentFormSchema = z.object({ const ZAttachmentFormSchema = z.object({
@@ -35,6 +41,7 @@ export const DocumentAttachmentsPopover = ({
envelopeId, envelopeId,
buttonClassName, buttonClassName,
buttonSize, buttonSize,
collapseLabelOnMobile = false,
}: DocumentAttachmentsPopoverProps) => { }: DocumentAttachmentsPopoverProps) => {
const { toast } = useToast(); const { toast } = useToast();
const { _ } = useLingui(); const { _ } = useLingui();
@@ -129,7 +136,7 @@ export const DocumentAttachmentsPopover = ({
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}> <Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
<Paperclip className="h-4 w-4" /> <Paperclip className="h-4 w-4" />
<span> <span className={cn(collapseLabelOnMobile && 'sr-only sm:not-sr-only')}>
<Trans>Attachments</Trans> <Trans>Attachments</Trans>
{attachments && attachments.data.length > 0 && <span className="ml-1">({attachments.data.length})</span>} {attachments && attachments.data.length > 0 && <span className="ml-1">({attachments.data.length})</span>}
</span> </span>
@@ -19,6 +19,12 @@ import { z } from 'zod';
export type EmbeddedEditorAttachmentPopoverProps = { export type EmbeddedEditorAttachmentPopoverProps = {
buttonClassName?: string; buttonClassName?: string;
buttonSize?: 'sm' | 'default'; buttonSize?: 'sm' | 'default';
/**
* Visually hide the button label below the `sm` breakpoint while keeping it
* available to screen readers.
*/
collapseLabelOnMobile?: boolean;
}; };
const ZAttachmentFormSchema = z.object({ const ZAttachmentFormSchema = z.object({
@@ -32,6 +38,7 @@ type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
export const EmbeddedEditorAttachmentPopover = ({ export const EmbeddedEditorAttachmentPopover = ({
buttonClassName, buttonClassName,
buttonSize, buttonSize,
collapseLabelOnMobile = false,
}: EmbeddedEditorAttachmentPopoverProps) => { }: EmbeddedEditorAttachmentPopoverProps) => {
const { toast } = useToast(); const { toast } = useToast();
const { _ } = useLingui(); const { _ } = useLingui();
@@ -90,7 +97,7 @@ export const EmbeddedEditorAttachmentPopover = ({
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}> <Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
<Paperclip className="h-4 w-4" /> <Paperclip className="h-4 w-4" />
<span> <span className={cn(collapseLabelOnMobile && 'sr-only sm:not-sr-only')}>
<Trans>Attachments</Trans> <Trans>Attachments</Trans>
{attachments.length > 0 && <span className="ml-1">({attachments.length})</span>} {attachments.length > 0 && <span className="ml-1">({attachments.length})</span>}
</span> </span>
@@ -24,6 +24,7 @@ import {
UserIcon, UserIcon,
} from 'lucide-react'; } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
const MIN_HEIGHT_PX = 12; const MIN_HEIGHT_PX = 12;
const MIN_WIDTH_PX = 36; const MIN_WIDTH_PX = 36;
@@ -88,11 +89,18 @@ export const fieldButtonList = [
type EnvelopeEditorFieldDragDropProps = { type EnvelopeEditorFieldDragDropProps = {
selectedRecipientId: number | null; selectedRecipientId: number | null;
selectedEnvelopeItemId: string | null; selectedEnvelopeItemId: string | null;
/**
* Called when the user picks a field type from the palette, before the field
* is placed on the document.
*/
onFieldSelect?: (fieldType: FieldType) => void;
}; };
export const EnvelopeEditorFieldDragDrop = ({ export const EnvelopeEditorFieldDragDrop = ({
selectedRecipientId, selectedRecipientId,
selectedEnvelopeItemId, selectedEnvelopeItemId,
onFieldSelect,
}: EnvelopeEditorFieldDragDropProps) => { }: EnvelopeEditorFieldDragDropProps) => {
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor(); const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
@@ -245,7 +253,10 @@ export const EnvelopeEditorFieldDragDrop = ({
disabled={isFieldsDisabled} disabled={isFieldsDisabled}
key={field.type} key={field.type}
type="button" type="button"
onClick={() => setSelectedField(field.type)} onClick={() => {
setSelectedField(field.type);
onFieldSelect?.(field.type);
}}
onMouseDown={() => setSelectedField(field.type)} onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined} data-selected={selectedField === field.type ? true : undefined}
className={cn( className={cn(
@@ -267,27 +278,35 @@ export const EnvelopeEditorFieldDragDrop = ({
))} ))}
</div> </div>
{selectedField && ( {/*
<div * Portalled to the body because the fields panel ancestor carries a CSS
className={cn( * transform (the slide-in overlay below `md`), which would otherwise
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted', * become the containing block for this fixed-position preview and pin
selectedRecipientStyles.base, * it inside the panel instead of the viewport.
selectedField === FieldType.SIGNATURE && 'font-signature', */}
{ {selectedField &&
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds, createPortal(
'dark:text-black/60': isFieldWithinBounds, <div
}, className={cn(
)} 'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted',
style={{ selectedRecipientStyles.base,
top: coords.y, selectedField === FieldType.SIGNATURE && 'font-signature',
left: coords.x, {
height: fieldBounds.current.height, '-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
width: fieldBounds.current.width, 'dark:text-black/60': isFieldWithinBounds,
}} },
> )}
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{t(FRIENDLY_FIELD_TYPE[selectedField])}</span> style={{
</div> top: coords.y,
)} left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{t(FRIENDLY_FIELD_TYPE[selectedField])}</span>
</div>,
document.body,
)}
</> </>
); );
}; };
@@ -30,7 +30,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client'; import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react'; import { AlertTriangleIcon, FileTextIcon, MousePointerClickIcon, PencilIcon, SparklesIcon, XIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router'; import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda'; import { isDeepEqual } from 'remeda';
@@ -89,6 +89,10 @@ export const EnvelopeEditorFieldsPage = () => {
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false); const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const { revalidate } = useRevalidator(); const { revalidate } = useRevalidator();
// Whether the fields panel overlay is open. Only relevant below the `md`
// breakpoint, where the panel is hidden behind a floating trigger button.
const [isMobileFieldsPanelOpen, setIsMobileFieldsPanelOpen] = useState(false);
const envelopeItemPermissions = useMemo( const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients), () => getEnvelopeItemPermissions(envelope, envelope.recipients),
[envelope, envelope.recipients], [envelope, envelope.recipients],
@@ -187,6 +191,10 @@ export const EnvelopeEditorFieldsPage = () => {
}, []); }, []);
const onDetectClick = () => { const onDetectClick = () => {
// Close the panel overlay on small viewports so the document stays
// visible while the AI dialogs are open.
setIsMobileFieldsPanelOpen(false);
if (!team.preferences.aiFeaturesEnabled) { if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true); setIsAiEnableDialogOpen(true);
return; return;
@@ -308,212 +316,264 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Right Section - Form Fields Panel */} {/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && envelope.recipients.length > 0 && ( {currentEnvelopeItem && envelope.recipients.length > 0 && (
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-border border-l bg-background py-4"> <>
{/* Recipient selector section. */} {/* Floating trigger for the fields panel, visible below the `md` breakpoint. */}
<section className="px-4"> <Button
<h3 className="mb-2 font-semibold text-foreground text-sm"> type="button"
<Trans>Selected Recipient</Trans> className="fixed right-4 bottom-4 z-30 shadow-lg md:hidden"
</h3> onClick={() => setIsMobileFieldsPanelOpen(true)}
>
<MousePointerClickIcon className="mr-2 h-4 w-4" />
<Trans>Fields</Trans>
</Button>
<EnvelopeRecipientSelector {isMobileFieldsPanelOpen && (
selectedRecipient={editorFields.selectedRecipient} <div
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)} role="presentation"
recipients={envelope.recipients} className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm md:hidden"
fields={envelope.fields} onClick={() => setIsMobileFieldsPanelOpen(false)}
className="w-full"
align="end"
/> />
)}
{editorFields.selectedRecipient && {/*
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && ( * Below `md` the panel is a slide-in overlay, from `md` upwards it is a
<Alert className="mt-4" variant="warning"> * static sidebar.
<AlertDescription> *
<Trans> * This is deliberately one always-mounted element toggled with CSS rather
This recipient can no longer be modified as they have signed a field, or completed the document. * than a Sheet/Drawer: the field palette registers window-level mouse
</Trans> * listeners that must survive the panel closing mid click-to-place, and
</AlertDescription> * unmounting would also double-mount the field settings forms and AI
</Alert> * dialogs. `invisible` (with the transition covering visibility) keeps the
)} * closed overlay out of the tab order and accessibility tree.
</section> */}
<div
<Separator className="my-4" /> className={cn(
'fixed inset-y-0 right-0 z-50 w-80 max-w-[85vw] overflow-y-auto border-border border-l bg-background py-4 transition-[transform,visibility] duration-200',
{/* Add fields section. */} isMobileFieldsPanelOpen ? 'translate-x-0 shadow-xl' : 'translate-x-full max-md:invisible',
<section className="px-4"> 'md:sticky md:top-0 md:z-auto md:h-full md:w-80 md:max-w-none md:flex-shrink-0 md:translate-x-0 md:shadow-none md:transition-none',
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)} )}
</section> >
{/* Panel header with close button, visible below the `md` breakpoint. */}
<div className="mb-4 flex items-center justify-between px-4 md:hidden">
<h3 className="font-semibold text-foreground text-sm">
<Trans>Fields</Trans>
</h3>
{/* Field details section. */} <Button variant="ghost" size="sm" onClick={() => setIsMobileFieldsPanelOpen(false)}>
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}> <XIcon className="h-4 w-4" />
{selectedField && ( <span className="sr-only">
<section> <Trans>Close</Trans>
<Separator className="my-4" /> </span>
</Button>
</div>
{searchParams.get('devmode') && ( {/* Recipient selector section. */}
<> <section className="px-4">
<div className="px-4"> <h3 className="mb-2 font-semibold text-foreground text-sm">
<h3 className="mb-3 font-semibold text-foreground text-sm"> <Trans>Selected Recipient</Trans>
<Trans>Developer Mode</Trans> </h3>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm"> <EnvelopeRecipientSelector
{selectedField.id && ( selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
align="end"
/>
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or completed the document.
</Trans>
</AlertDescription>
</Alert>
)}
</section>
<Separator className="my-4" />
{/* Add fields section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
onFieldSelect={() => setIsMobileFieldsPanelOpen(false)}
/>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
</section>
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && (
<section>
<Separator className="my-4" />
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 font-semibold text-foreground text-sm">
<Trans>Developer Mode</Trans>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
{selectedField.id && (
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans>
</span>{' '}
{selectedField.id}
</p>
)}
<p> <p>
<span className="min-w-12 text-muted-foreground"> <span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans> <Trans>Recipient ID:</Trans>
</span>{' '} </span>{' '}
{selectedField.id} {selectedField.recipientId}
</p> </p>
)} <p>
<p> <span className="min-w-12 text-muted-foreground">
<span className="min-w-12 text-muted-foreground"> <Trans>Pos X:</Trans>
<Trans>Recipient ID:</Trans> </span>{' '}
</span>{' '} {selectedField.positionX.toFixed(2)}
{selectedField.recipientId} </p>
</p> <p>
<p> <span className="min-w-12 text-muted-foreground">
<span className="min-w-12 text-muted-foreground"> <Trans>Pos Y:</Trans>
<Trans>Pos X:</Trans> </span>{' '}
</span>{' '} {selectedField.positionY.toFixed(2)}
{selectedField.positionX.toFixed(2)} </p>
</p> <p>
<p> <span className="min-w-12 text-muted-foreground">
<span className="min-w-12 text-muted-foreground"> <Trans>Width:</Trans>
<Trans>Pos Y:</Trans> </span>{' '}
</span>{' '} {selectedField.width.toFixed(2)}
{selectedField.positionY.toFixed(2)} </p>
</p> <p>
<p> <span className="min-w-12 text-muted-foreground">
<span className="min-w-12 text-muted-foreground"> <Trans>Height:</Trans>
<Trans>Width:</Trans> </span>{' '}
</span>{' '} {selectedField.height.toFixed(2)}
{selectedField.width.toFixed(2)} </p>
</p> </div>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
</div> </div>
</div>
<Separator className="my-4" /> <Separator className="my-4" />
</> </>
)} )}
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs"> <div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">{_(FieldSettingsTypeTranslations[selectedField.type])}</h3> <h3 className="font-semibold text-sm">{_(FieldSettingsTypeTranslations[selectedField.type])}</h3>
{match(selectedField.type) {match(selectedField.type)
.with(FieldType.SIGNATURE, () => ( .with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm <EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined} value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)} onValueChange={(value) => updateSelectedFieldMeta(value)}
/> />
)) ))
.with(FieldType.CHECKBOX, () => ( .with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm <EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined} value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)} onValueChange={(value) => updateSelectedFieldMeta(value)}
/> />
)) ))
.with(FieldType.DATE, () => ( .with(FieldType.DATE, () => (
<EditorFieldDateForm <EditorFieldDateForm
value={selectedField?.fieldMeta as TDateFieldMeta | undefined} value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)} onValueChange={(value) => updateSelectedFieldMeta(value)}
/> />
)) ))
.with(FieldType.DROPDOWN, () => ( .with(FieldType.DROPDOWN, () => (
<EditorFieldDropdownForm <EditorFieldDropdownForm
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined} value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)} onValueChange={(value) => updateSelectedFieldMeta(value)}
/> />
)) ))
.with(FieldType.EMAIL, () => ( .with(FieldType.EMAIL, () => (
<EditorFieldEmailForm <EditorFieldEmailForm
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined} value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)} onValueChange={(value) => updateSelectedFieldMeta(value)}
/> />
)) ))
.with(FieldType.INITIALS, () => ( .with(FieldType.INITIALS, () => (
<EditorFieldInitialsForm <EditorFieldInitialsForm
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined} value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)} onValueChange={(value) => updateSelectedFieldMeta(value)}
/> />
)) ))
.with(FieldType.NAME, () => ( .with(FieldType.NAME, () => (
<EditorFieldNameForm <EditorFieldNameForm
value={selectedField?.fieldMeta as TNameFieldMeta | undefined} value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)} onValueChange={(value) => updateSelectedFieldMeta(value)}
/> />
)) ))
.with(FieldType.NUMBER, () => ( .with(FieldType.NUMBER, () => (
<EditorFieldNumberForm <EditorFieldNumberForm
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined} value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)} onValueChange={(value) => updateSelectedFieldMeta(value)}
/> />
)) ))
.with(FieldType.RADIO, () => ( .with(FieldType.RADIO, () => (
<EditorFieldRadioForm <EditorFieldRadioForm
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined} value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)} onValueChange={(value) => updateSelectedFieldMeta(value)}
/> />
)) ))
.with(FieldType.TEXT, () => ( .with(FieldType.TEXT, () => (
<EditorFieldTextForm <EditorFieldTextForm
value={selectedField?.fieldMeta as TTextFieldMeta | undefined} value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)} onValueChange={(value) => updateSelectedFieldMeta(value)}
/> />
)) ))
.otherwise(() => null)} .otherwise(() => null)}
</div> </div>
</section> </section>
)} )}
</AnimateGenericFadeInOut> </AnimateGenericFadeInOut>
</div> </div>
</>
)} )}
</div> </div>
); );
@@ -70,17 +70,18 @@ export default function EnvelopeEditorHeader() {
return ( return (
<nav className="w-full border-border border-b bg-background px-4 py-3 md:px-6"> <nav className="w-full border-border border-b bg-background px-4 py-3 md:px-6">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 flex-1 items-center space-x-4"> <div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-4">
{/* The logo and separator are hidden on small viewports to leave room for the title. */}
{editorConfig.embedded?.customBrandingLogo ? ( {editorConfig.embedded?.customBrandingLogo ? (
<img src={`/api/branding/logo/team/${envelope.teamId}`} alt="Logo" className="h-6 w-auto" /> <img src={`/api/branding/logo/team/${envelope.teamId}`} alt="Logo" className="hidden h-6 w-auto sm:block" />
) : ( ) : (
<Link to="/"> <Link to="/" className="hidden sm:block">
<BrandingLogo className="h-6 w-auto" /> <BrandingLogo className="h-6 w-auto" />
</Link> </Link>
)} )}
<Separator orientation="vertical" className="h-6 shrink-0" /> <Separator orientation="vertical" className="hidden h-6 shrink-0 sm:block" />
<div className="flex min-w-0 items-center space-x-2"> <div className="flex min-w-0 items-center gap-2">
<EnvelopeItemTitleInput <EnvelopeItemTitleInput
dataTestId="envelope-title-input" dataTestId="envelope-title-input"
disabled={!envelopeItemPermissions.canTitleBeChanged || !allowConfigureEnvelopeTitle} disabled={!envelopeItemPermissions.canTitleBeChanged || !allowConfigureEnvelopeTitle}
@@ -98,19 +99,19 @@ export default function EnvelopeEditorHeader() {
{envelope.type === EnvelopeType.TEMPLATE && ( {envelope.type === EnvelopeType.TEMPLATE && (
<> <>
{envelope.templateType === TemplateType.PRIVATE && ( {envelope.templateType === TemplateType.PRIVATE && (
<Badge variant="secondary" className="shrink-0"> <Badge variant="secondary" className="hidden shrink-0 sm:inline-flex">
<LockIcon className="mr-2 h-4 w-4 text-blue-600 dark:text-blue-300" /> <LockIcon className="mr-2 h-4 w-4 text-blue-600 dark:text-blue-300" />
<Trans>Private Template</Trans> <Trans>Private Template</Trans>
</Badge> </Badge>
)} )}
{envelope.templateType === TemplateType.ORGANISATION && ( {envelope.templateType === TemplateType.ORGANISATION && (
<Badge variant="orange" className="shrink-0"> <Badge variant="orange" className="hidden shrink-0 sm:inline-flex">
<Building2Icon className="mr-2 size-4" /> <Building2Icon className="mr-2 size-4" />
<Trans>Organisation Template</Trans> <Trans>Organisation Template</Trans>
</Badge> </Badge>
)} )}
{envelope.templateType === TemplateType.PUBLIC && ( {envelope.templateType === TemplateType.PUBLIC && (
<Badge variant="default" className="shrink-0"> <Badge variant="default" className="hidden shrink-0 sm:inline-flex">
<Globe2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" /> <Globe2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
<Trans>Public Template</Trans> <Trans>Public Template</Trans>
</Badge> </Badge>
@@ -118,7 +119,7 @@ export default function EnvelopeEditorHeader() {
{envelope.directLink?.token && ( {envelope.directLink?.token && (
<TemplateDirectLinkBadge <TemplateDirectLinkBadge
className="shrink-0 py-1" className="hidden shrink-0 py-1 sm:flex"
token={envelope.directLink.token} token={envelope.directLink.token}
enabled={envelope.directLink.enabled} enabled={envelope.directLink.enabled}
/> />
@@ -180,9 +181,9 @@ export default function EnvelopeEditorHeader() {
<div className="flex shrink-0 items-center space-x-2"> <div className="flex shrink-0 items-center space-x-2">
{allowAttachments && {allowAttachments &&
(isEmbedded ? ( (isEmbedded ? (
<EmbeddedEditorAttachmentPopover buttonSize="sm" /> <EmbeddedEditorAttachmentPopover buttonSize="sm" collapseLabelOnMobile />
) : ( ) : (
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" /> <DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" collapseLabelOnMobile />
))} ))}
{editorConfig.settings && ( {editorConfig.settings && (
@@ -202,8 +203,10 @@ export default function EnvelopeEditorHeader() {
documentRootPath={relativePath.documentRootPath} documentRootPath={relativePath.documentRootPath}
trigger={ trigger={
<Button size="sm"> <Button size="sm">
<SendIcon className="mr-2 h-4 w-4" /> <SendIcon className="h-4 w-4 sm:mr-2" />
<Trans>Send Document</Trans> <span className="sr-only sm:not-sr-only">
<Trans>Send Document</Trans>
</span>
</Button> </Button>
} }
/> />
@@ -212,8 +215,10 @@ export default function EnvelopeEditorHeader() {
envelope={envelope} envelope={envelope}
trigger={ trigger={
<Button size="sm"> <Button size="sm">
<SendIcon className="mr-2 h-4 w-4" /> <SendIcon className="h-4 w-4 sm:mr-2" />
<Trans>Resend Document</Trans> <span className="sr-only sm:not-sr-only">
<Trans>Resend Document</Trans>
</span>
</Button> </Button>
} }
/> />
@@ -592,7 +592,7 @@ export const EnvelopeEditorRecipientForm = () => {
return ( return (
<Card backdropBlur={false} className="border"> <Card backdropBlur={false} className="border">
<CardHeader className="flex flex-row justify-between"> <CardHeader className="flex flex-col justify-between gap-4 space-y-0 sm:flex-row">
<div> <div>
<CardTitle> <CardTitle>
<Trans>Recipients</Trans> <Trans>Recipients</Trans>
@@ -602,7 +602,7 @@ export const EnvelopeEditorRecipientForm = () => {
</CardDescription> </CardDescription>
</div> </div>
<div className="flex flex-row items-center space-x-2"> <div className="flex flex-row flex-wrap items-center gap-2">
{editorConfig.recipients?.allowAIDetection && ( {editorConfig.recipients?.allowAIDetection && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -842,9 +842,9 @@ export const EnvelopeEditorRecipientForm = () => {
'pr-3': isSigningOrderSequential, 'pr-3': isSigningOrderSequential,
})} })}
> >
<div className="flex flex-row items-center gap-x-2"> <div className="flex flex-row flex-wrap items-center gap-2 sm:flex-nowrap">
{isSigningOrderSequential && isCcRecipient(signer) && ( {isSigningOrderSequential && isCcRecipient(signer) && (
<div className="mt-auto h-10 w-[4.25rem] flex-shrink-0" /> <div className="mt-auto hidden h-10 w-[4.25rem] flex-shrink-0 sm:block" />
)} )}
{isSigningOrderSequential && !isCcRecipient(signer) && ( {isSigningOrderSequential && !isCcRecipient(signer) && (
@@ -900,8 +900,9 @@ export const EnvelopeEditorRecipientForm = () => {
!form.formState.errors.signers[index]?.email, !form.formState.errors.signers[index]?.email,
})} })}
> >
{!showAdvancedSettings && index === 0 && ( {/* Below `sm` the rows are stacked, so every row needs its own labels. */}
<FormLabel> {!showAdvancedSettings && (
<FormLabel className={cn(index !== 0 && 'sm:hidden')}>
<Trans>Email</Trans> <Trans>Email</Trans>
</FormLabel> </FormLabel>
)} )}
@@ -947,8 +948,8 @@ export const EnvelopeEditorRecipientForm = () => {
!form.formState.errors.signers[index]?.name, !form.formState.errors.signers[index]?.name,
})} })}
> >
{!showAdvancedSettings && index === 0 && ( {!showAdvancedSettings && (
<FormLabel> <FormLabel className={cn(index !== 0 && 'sm:hidden')}>
<Trans>Name</Trans> <Trans>Name</Trans>
</FormLabel> </FormLabel>
)} )}
@@ -448,7 +448,7 @@ export const EnvelopeEditorUploadPage = () => {
}; };
return ( return (
<div className="mx-auto max-w-4xl space-y-6 p-8"> <div className="mx-auto max-w-4xl space-y-6 p-4 md:p-8">
<input {...getReplaceInputProps()} /> <input {...getReplaceInputProps()} />
<EnvelopeEditorInvalidDirectTemplateAlert className="max-w-none" /> <EnvelopeEditorInvalidDirectTemplateAlert className="max-w-none" />
@@ -1,3 +1,4 @@
import { useIsMobileViewport } from '@documenso/lib/client-only/hooks/use-media-query';
import type { EnvelopeEditorStep } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import type { EnvelopeEditorStep } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
@@ -89,6 +90,8 @@ export const EnvelopeEditor = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const isMobileViewport = useIsMobileViewport();
const { const {
general: { minimizeLeftSidebar, allowUploadAndRecipientStep, allowAddFieldsStep, allowPreviewStep }, general: { minimizeLeftSidebar, allowUploadAndRecipientStep, allowAddFieldsStep, allowPreviewStep },
actions: { actions: {
@@ -101,6 +104,10 @@ export const EnvelopeEditor = () => {
}, },
} = editorConfig; } = editorConfig;
// Fall back to the icon-only sidebar on small viewports since the full
// sidebar would consume most of the screen.
const isSidebarMinimized = minimizeLeftSidebar || isMobileViewport;
const envelopeEditorSteps = useMemo(() => { const envelopeEditorSteps = useMemo(() => {
const steps: EnvelopeEditorStepData[] = []; const steps: EnvelopeEditorStepData[] = [];
@@ -175,19 +182,19 @@ export const EnvelopeEditor = () => {
const currentStepData = envelopeEditorSteps.find((step) => step.id === searchParamsStep) || envelopeEditorSteps[0]; const currentStepData = envelopeEditorSteps.find((step) => step.id === searchParamsStep) || envelopeEditorSteps[0];
return ( return (
<div className="h-screen w-screen bg-envelope-editor-background"> <div className="h-[100dvh] w-full bg-envelope-editor-background">
<EnvelopeEditorHeader /> <EnvelopeEditorHeader />
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen"> <div className="flex h-[calc(100dvh-4rem)] w-full">
{/* Left Section - Step Navigation */} {/* Left Section - Step Navigation */}
<div <div
className={cn('flex w-80 flex-shrink-0 flex-col overflow-y-auto border-border border-r bg-background py-4', { className={cn('flex w-80 flex-shrink-0 flex-col overflow-y-auto border-border border-r bg-background py-4', {
'w-14': minimizeLeftSidebar, 'w-14': isSidebarMinimized,
})} })}
> >
{/* Left section step selector. */} {/* Left section step selector. */}
{minimizeLeftSidebar ? ( {isSidebarMinimized ? (
<div className="flex justify-center px-4"> <div className="flex justify-center px-4">
<div className="relative flex h-10 w-10 items-center justify-center"> <div className="relative flex h-10 w-10 items-center justify-center">
<svg className="size-10 -rotate-90" viewBox="0 0 40 40" aria-hidden> <svg className="size-10 -rotate-90" viewBox="0 0 40 40" aria-hidden>
@@ -254,8 +261,8 @@ export const EnvelopeEditor = () => {
<div <div
className={cn('space-y-3', { className={cn('space-y-3', {
'px-4': !minimizeLeftSidebar, 'px-4': !isSidebarMinimized,
'mt-4 flex flex-col items-center': minimizeLeftSidebar, 'mt-4 flex flex-col items-center': isSidebarMinimized,
})} })}
> >
{envelopeEditorSteps.map((step) => { {envelopeEditorSteps.map((step) => {
@@ -274,7 +281,7 @@ export const EnvelopeEditor = () => {
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10' : 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
}`, }`,
{ {
'p-3': !minimizeLeftSidebar, 'p-3': !isSidebarMinimized,
}, },
)} )}
onClick={() => void navigateToStep(step.id as EnvelopeEditorStep)} onClick={() => void navigateToStep(step.id as EnvelopeEditorStep)}
@@ -290,7 +297,7 @@ export const EnvelopeEditor = () => {
<Icon className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`} /> <Icon className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`} />
</div> </div>
{!minimizeLeftSidebar && ( {!isSidebarMinimized && (
<div> <div>
<div <div
className={`font-medium text-sm ${ className={`font-medium text-sm ${
@@ -312,17 +319,17 @@ export const EnvelopeEditor = () => {
<Separator <Separator
className={cn('my-6', { className={cn('my-6', {
'mx-auto mb-4 w-4/5': minimizeLeftSidebar, 'mx-auto mb-4 w-4/5': isSidebarMinimized,
})} })}
/> />
{/* Quick Actions. */} {/* Quick Actions. */}
<div <div
className={cn('space-y-3 px-4 [&_.lucide]:text-muted-foreground', { className={cn('space-y-3 px-4 [&_.lucide]:text-muted-foreground', {
'px-2': minimizeLeftSidebar, 'px-2': isSidebarMinimized,
})} })}
> >
{!minimizeLeftSidebar && ( {!isSidebarMinimized && (
<h4 className="font-semibold text-foreground text-sm"> <h4 className="font-semibold text-foreground text-sm">
<Trans>Quick Actions</Trans> <Trans>Quick Actions</Trans>
</h4> </h4>
@@ -334,7 +341,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Settings`)}> <Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Settings`)}>
<SettingsIcon className="h-4 w-4" /> <SettingsIcon className="h-4 w-4" />
{!minimizeLeftSidebar && ( {!isSidebarMinimized && (
<span className="ml-2"> <span className="ml-2">
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>} {isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
</span> </span>
@@ -352,7 +359,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Send Envelope`)}> <Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Send Envelope`)}>
<SendIcon className="h-4 w-4" /> <SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && ( {!isSidebarMinimized && (
<span className="ml-2"> <span className="ml-2">
<Trans>Send Document</Trans> <Trans>Send Document</Trans>
</span> </span>
@@ -367,7 +374,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Resend Envelope`)}> <Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Resend Envelope`)}>
<SendIcon className="h-4 w-4" /> <SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && ( {!isSidebarMinimized && (
<span className="ml-2"> <span className="ml-2">
<Trans>Resend Document</Trans> <Trans>Resend Document</Trans>
</span> </span>
@@ -389,7 +396,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Direct Link`)}> <Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Direct Link`)}>
<LinkIcon className="h-4 w-4" /> <LinkIcon className="h-4 w-4" />
{!minimizeLeftSidebar && ( {!isSidebarMinimized && (
<span className="ml-2"> <span className="ml-2">
<Trans>Direct Link</Trans> <Trans>Direct Link</Trans>
</span> </span>
@@ -407,7 +414,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Duplicate Envelope`)}> <Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Duplicate Envelope`)}>
<CopyPlusIcon className="h-4 w-4" /> <CopyPlusIcon className="h-4 w-4" />
{!minimizeLeftSidebar && ( {!isSidebarMinimized && (
<span className="ml-2"> <span className="ml-2">
{isDocument ? <Trans>Duplicate Document</Trans> : <Trans>Duplicate Template</Trans>} {isDocument ? <Trans>Duplicate Document</Trans> : <Trans>Duplicate Template</Trans>}
</span> </span>
@@ -424,7 +431,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Save as Template`)}> <Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Save as Template`)}>
<FileOutputIcon className="h-4 w-4" /> <FileOutputIcon className="h-4 w-4" />
{!minimizeLeftSidebar && ( {!isSidebarMinimized && (
<span className="ml-2"> <span className="ml-2">
<Trans>Save as Template</Trans> <Trans>Save as Template</Trans>
</span> </span>
@@ -444,7 +451,7 @@ export const EnvelopeEditor = () => {
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Download PDF`)}> <Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Download PDF`)}>
<DownloadCloudIcon className="h-4 w-4" /> <DownloadCloudIcon className="h-4 w-4" />
{!minimizeLeftSidebar && ( {!isSidebarMinimized && (
<span className="ml-2"> <span className="ml-2">
<Trans>Download PDF</Trans> <Trans>Download PDF</Trans>
</span> </span>
@@ -472,7 +479,7 @@ export const EnvelopeEditor = () => {
> >
<Trash2Icon className="h-4 w-4" /> <Trash2Icon className="h-4 w-4" />
{!minimizeLeftSidebar && ( {!isSidebarMinimized && (
<span className="ml-2"> <span className="ml-2">
{isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>} {isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>}
</span> </span>
@@ -494,20 +501,20 @@ export const EnvelopeEditor = () => {
{!editorConfig.embedded && ( {!editorConfig.embedded && (
<div <div
className={cn('mt-auto px-4', { className={cn('mt-auto px-4', {
'px-2': minimizeLeftSidebar, 'px-2': isSidebarMinimized,
})} })}
> >
<Button <Button
variant="ghost" variant="ghost"
className={cn('w-full justify-start', { className={cn('w-full justify-start', {
'flex items-center justify-center': minimizeLeftSidebar, 'flex items-center justify-center': isSidebarMinimized,
})} })}
asChild asChild
> >
<Link to={relativePath.basePath}> <Link to={relativePath.basePath}>
<ArrowLeftIcon className="h-4 w-4 flex-shrink-0" /> <ArrowLeftIcon className="h-4 w-4 flex-shrink-0" />
{!minimizeLeftSidebar && ( {!isSidebarMinimized && (
<span className="ml-2"> <span className="ml-2">
{isDocument ? <Trans>Return to documents</Trans> : <Trans>Return to templates</Trans>} {isDocument ? <Trans>Return to documents</Trans> : <Trans>Return to templates</Trans>}
</span> </span>
@@ -0,0 +1,34 @@
import { useCallback, useSyncExternalStore } from 'react';
/**
* Tracks whether the given CSS media query currently matches.
*
* Returns `false` on the server since the viewport is unknown until hydration.
*/
export const useMediaQuery = (query: string) => {
const subscribe = useCallback(
(onStoreChange: () => void) => {
const mediaQueryList = window.matchMedia(query);
mediaQueryList.addEventListener('change', onStoreChange);
return () => mediaQueryList.removeEventListener('change', onStoreChange);
},
[query],
);
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
// The server snapshot: the viewport is unknown during SSR, so we assume
// the query does not match rather than throwing. React re-renders with
// the real value after hydration, so the only cost is a brief first
// paint with the non-matching variant.
() => false,
);
};
/**
* Whether the viewport is below the Tailwind `md` breakpoint (768px).
*/
export const useIsMobileViewport = () => useMediaQuery('(max-width: 767px)');