feat: horizontal checkboxes (#1911)

Adds the ability to have checkboxes align horizontally, wrapping when
they would go off the PDF
This commit is contained in:
Lucas Smith
2025-07-19 22:06:50 +10:00
committed by GitHub
parent c47dc8749a
commit 512e3555b4
10 changed files with 169 additions and 42 deletions

View File

@ -17,6 +17,7 @@ import type {
TSignFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants'; import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
@ -276,7 +277,14 @@ export const DocumentSigningCheckboxField = ({
{validationSign?.label} {checkboxValidationLength} {validationSign?.label} {checkboxValidationLength}
</FieldToolTip> </FieldToolTip>
)} )}
<div className="z-50 my-0.5 flex flex-col gap-y-1"> <div
className={cn(
'z-50 my-0.5 flex gap-1',
parsedFieldMeta.direction === 'horizontal'
? 'flex-row flex-wrap'
: 'flex-col gap-y-1',
)}
>
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => { {values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`; const itemValue = item.value || `empty-value-${item.id}`;
@ -305,7 +313,12 @@ export const DocumentSigningCheckboxField = ({
)} )}
{field.inserted && ( {field.inserted && (
<div className="my-0.5 flex flex-col gap-y-1"> <div
className={cn(
'my-0.5 flex gap-1',
parsedFieldMeta.direction === 'horizontal' ? 'flex-row flex-wrap' : 'flex-col gap-y-1',
)}
>
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => { {values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
const itemValue = item.value || `empty-value-${item.id}`; const itemValue = item.value || `empty-value-${item.id}`;

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
@ -10,7 +10,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
width: 0, width: 0,
}); });
const calculateBounds = () => { const calculateBounds = useCallback(() => {
const $el = const $el =
typeof elementOrSelector === 'string' typeof elementOrSelector === 'string'
? document.querySelector<HTMLElement>(elementOrSelector) ? document.querySelector<HTMLElement>(elementOrSelector)
@ -32,11 +32,11 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
width, width,
height, height,
}; };
}; }, [elementOrSelector, withScroll]);
useEffect(() => { useEffect(() => {
setBounds(calculateBounds()); setBounds(calculateBounds());
}, [calculateBounds]); }, []);
useEffect(() => { useEffect(() => {
const onResize = () => { const onResize = () => {
@ -48,7 +48,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
return () => { return () => {
window.removeEventListener('resize', onResize); window.removeEventListener('resize', onResize);
}; };
}, [calculateBounds]); }, []);
useEffect(() => { useEffect(() => {
const $el = const $el =
@ -69,7 +69,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
return () => { return () => {
observer.disconnect(); observer.disconnect();
}; };
}, [calculateBounds]); }, []);
return bounds; return bounds;
}; };

View File

@ -240,35 +240,79 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
})); }));
const selected: string[] = fromCheckboxValue(field.customText); const selected: string[] = fromCheckboxValue(field.customText);
const direction = meta.data.direction ?? 'vertical';
const topPadding = 12; const topPadding = 12;
const leftCheckboxPadding = 8; const leftCheckboxPadding = 8;
const leftCheckboxLabelPadding = 12; const leftCheckboxLabelPadding = 12;
const checkboxSpaceY = 13; const checkboxSpaceY = 13;
for (const [index, item] of (values ?? []).entries()) { if (direction === 'horizontal') {
const offsetY = index * checkboxSpaceY + topPadding; // Horizontal layout: arrange checkboxes side by side with wrapping
let currentX = leftCheckboxPadding;
let currentY = topPadding;
const maxWidth = pageWidth - fieldX - leftCheckboxPadding * 2;
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`); for (const [index, item] of (values ?? []).entries()) {
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
if (selected.includes(item.value)) { if (selected.includes(item.value)) {
checkbox.check(); checkbox.check();
}
const labelText = item.value.includes('empty-value-') ? '' : item.value;
const labelWidth = font.widthOfTextAtSize(labelText, 12);
const itemWidth = leftCheckboxLabelPadding + labelWidth + 16; // checkbox + padding + label + margin
// Check if item fits on current line, if not wrap to next line
if (currentX + itemWidth > maxWidth && index > 0) {
currentX = leftCheckboxPadding;
currentY += checkboxSpaceY;
}
page.drawText(labelText, {
x: fieldX + currentX + leftCheckboxLabelPadding,
y: pageHeight - (fieldY + currentY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
checkbox.addToPage(page, {
x: fieldX + currentX,
y: pageHeight - (fieldY + currentY),
height: 8,
width: 8,
});
currentX += itemWidth;
} }
} else {
// Vertical layout: original behavior
for (const [index, item] of (values ?? []).entries()) {
const offsetY = index * checkboxSpaceY + topPadding;
page.drawText(item.value.includes('empty-value-') ? '' : item.value, { const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
x: fieldX + leftCheckboxPadding + leftCheckboxLabelPadding,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
checkbox.addToPage(page, { if (selected.includes(item.value)) {
x: fieldX + leftCheckboxPadding, checkbox.check();
y: pageHeight - (fieldY + offsetY), }
height: 8,
width: 8, page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
}); x: fieldX + leftCheckboxPadding + leftCheckboxLabelPadding,
y: pageHeight - (fieldY + offsetY),
size: 12,
font,
rotate: degrees(pageRotationInDegrees),
});
checkbox.addToPage(page, {
x: fieldX + leftCheckboxPadding,
y: pageHeight - (fieldY + offsetY),
height: 8,
width: 8,
});
}
} }
}) })
.with({ type: FieldType.RADIO }, (field) => { .with({ type: FieldType.RADIO }, (field) => {

View File

@ -228,6 +228,7 @@ const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillField
type: 'checkbox', type: 'checkbox',
label: field.label, label: field.label,
values: newValues, values: newValues,
direction: checkboxMeta.direction ?? 'vertical',
}; };
return meta; return meta;

View File

@ -96,6 +96,7 @@ export const ZCheckboxFieldMeta = ZBaseFieldMeta.extend({
.optional(), .optional(),
validationRule: z.string().optional(), validationRule: z.string().optional(),
validationLength: z.number().optional(), validationLength: z.number().optional(),
direction: z.enum(['vertical', 'horizontal']).optional().default('vertical'),
}); });
export type TCheckboxFieldMeta = z.infer<typeof ZCheckboxFieldMeta>; export type TCheckboxFieldMeta = z.infer<typeof ZCheckboxFieldMeta>;

View File

@ -3,7 +3,9 @@ import React, { useEffect, useMemo, useState } from 'react';
import { type Field, FieldType } from '@prisma/client'; import { type Field, FieldType } from '@prisma/client';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords'; import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import type { RecipientColorStyles } from '../../lib/recipient-colors'; import type { RecipientColorStyles } from '../../lib/recipient-colors';
@ -23,6 +25,11 @@ export function FieldContainerPortal({
const alternativePortalRoot = document.getElementById('document-field-portal-root'); const alternativePortalRoot = document.getElementById('document-field-portal-root');
const coords = useFieldPageCoords(field); const coords = useFieldPageCoords(field);
const $pageBounds = useElementBounds(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
const maxWidth = $pageBounds?.width ? $pageBounds.width - coords.x : undefined;
const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO'; const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO';
@ -32,10 +39,14 @@ export function FieldContainerPortal({
const bounds = { const bounds = {
top: `${coords.y}px`, top: `${coords.y}px`,
left: `${coords.x}px`, left: `${coords.x}px`,
...(!isCheckboxOrRadioField && { ...(!isCheckboxOrRadioField
height: `${coords.height}px`, ? {
width: `${coords.width}px`, height: `${coords.height}px`,
}), width: `${coords.width}px`,
}
: {
maxWidth: `${maxWidth}px`,
}),
}; };
if (portalBounds) { if (portalBounds) {
@ -44,7 +55,7 @@ export function FieldContainerPortal({
} }
return bounds; return bounds;
}, [coords, isCheckboxOrRadioField]); }, [coords, maxWidth, isCheckboxOrRadioField]);
return createPortal( return createPortal(
<div className={cn('absolute', className)} style={style}> <div className={cn('absolute', className)} style={style}>

View File

@ -38,13 +38,8 @@ export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
const { type, fieldMeta } = field; const { type, fieldMeta } = field;
// Only render checkbox if values exist, otherwise render the empty checkbox field content. // Render checkbox layout for checkbox fields, even if no values exist yet
if ( if (field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox') {
field.type === FieldType.CHECKBOX &&
field.fieldMeta?.type === 'checkbox' &&
field.fieldMeta.values &&
field.fieldMeta.values.length > 0
) {
let checkedValues: string[] = []; let checkedValues: string[] = [];
try { try {
@ -55,8 +50,32 @@ export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
console.error(err); console.error(err);
} }
// If no values exist yet, show a placeholder checkbox
if (!field.fieldMeta.values || field.fieldMeta.values.length === 0) {
return (
<div
className={cn(
'flex gap-1 py-0.5',
field.fieldMeta.direction === 'horizontal' ? 'flex-row flex-wrap' : 'flex-col gap-y-1',
)}
>
<div className="flex items-center">
<Checkbox className="h-3 w-3" disabled />
<Label className="text-foreground ml-1.5 text-xs font-normal opacity-50">
Checkbox option
</Label>
</div>
</div>
);
}
return ( return (
<div className="flex flex-col gap-y-1 py-0.5"> <div
className={cn(
'flex gap-1 py-0.5',
field.fieldMeta.direction === 'horizontal' ? 'flex-row flex-wrap' : 'flex-col gap-y-1',
)}
>
{field.fieldMeta.values.map((item, index) => ( {field.fieldMeta.values.map((item, index) => (
<div key={index} className="flex items-center"> <div key={index} className="flex items-center">
<Checkbox <Checkbox

View File

@ -129,6 +129,7 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
validationLength: 0, validationLength: 0,
required: false, required: false,
readOnly: false, readOnly: false,
direction: 'vertical',
}; };
case FieldType.DROPDOWN: case FieldType.DROPDOWN:
return { return {

View File

@ -8,6 +8,7 @@ import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd'; import { Rnd } from 'react-rnd';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta'; import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta'; import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
@ -81,7 +82,11 @@ export const FieldItem = ({
pageWidth: defaultWidth || 0, pageWidth: defaultWidth || 0,
}); });
const [settingsActive, setSettingsActive] = useState(false); const [settingsActive, setSettingsActive] = useState(false);
const $el = useRef(null); const $el = useRef<HTMLDivElement>(null);
const $pageBounds = useElementBounds(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
const signerStyles = useRecipientColors(recipientIndex); const signerStyles = useRecipientColors(recipientIndex);
@ -233,9 +238,10 @@ export const FieldItem = ({
default={{ default={{
x: coords.pageX, x: coords.pageX,
y: coords.pageY, y: coords.pageY,
height: fixedSize ? '' : coords.pageHeight, height: fixedSize ? 'auto' : coords.pageHeight,
width: fixedSize ? '' : coords.pageWidth, width: fixedSize ? 'auto' : coords.pageWidth,
}} }}
maxWidth={fixedSize && $pageBounds?.width ? $pageBounds.width - coords.pageX : undefined}
bounds={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`} bounds={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`}
onDragStart={() => onFieldActivate?.()} onDragStart={() => onFieldActivate?.()}
onResizeStart={() => onFieldActivate?.()} onResizeStart={() => onFieldActivate?.()}

View File

@ -44,6 +44,9 @@ export const CheckboxFieldAdvancedSettings = ({
const [required, setRequired] = useState(fieldState.required ?? false); const [required, setRequired] = useState(fieldState.required ?? false);
const [validationLength, setValidationLength] = useState(fieldState.validationLength ?? 0); const [validationLength, setValidationLength] = useState(fieldState.validationLength ?? 0);
const [validationRule, setValidationRule] = useState(fieldState.validationRule ?? ''); const [validationRule, setValidationRule] = useState(fieldState.validationRule ?? '');
const [direction, setDirection] = useState<'vertical' | 'horizontal'>(
fieldState.direction ?? 'vertical',
);
const handleToggleChange = (field: keyof CheckboxFieldMeta, value: string | boolean) => { const handleToggleChange = (field: keyof CheckboxFieldMeta, value: string | boolean) => {
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly); const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
@ -52,11 +55,14 @@ export const CheckboxFieldAdvancedSettings = ({
field === 'validationRule' ? String(value) : String(fieldState.validationRule); field === 'validationRule' ? String(value) : String(fieldState.validationRule);
const validationLength = const validationLength =
field === 'validationLength' ? Number(value) : Number(fieldState.validationLength); field === 'validationLength' ? Number(value) : Number(fieldState.validationLength);
const currentDirection =
field === 'direction' && String(value) === 'horizontal' ? 'horizontal' : 'vertical';
setReadOnly(readOnly); setReadOnly(readOnly);
setRequired(required); setRequired(required);
setValidationRule(validationRule); setValidationRule(validationRule);
setValidationLength(validationLength); setValidationLength(validationLength);
setDirection(currentDirection);
const errors = validateCheckboxField( const errors = validateCheckboxField(
values.map((item) => item.value), values.map((item) => item.value),
@ -65,6 +71,7 @@ export const CheckboxFieldAdvancedSettings = ({
required, required,
validationRule, validationRule,
validationLength, validationLength,
direction: currentDirection,
type: 'checkbox', type: 'checkbox',
}, },
); );
@ -86,6 +93,7 @@ export const CheckboxFieldAdvancedSettings = ({
required, required,
validationRule, validationRule,
validationLength, validationLength,
direction: direction,
type: 'checkbox', type: 'checkbox',
}, },
); );
@ -137,6 +145,29 @@ export const CheckboxFieldAdvancedSettings = ({
onChange={(e) => handleFieldChange('label', e.target.value)} onChange={(e) => handleFieldChange('label', e.target.value)}
/> />
</div> </div>
<div className="mb-2">
<Label>
<Trans>Direction</Trans>
</Label>
<Select
value={fieldState.direction ?? 'vertical'}
onValueChange={(val) => handleToggleChange('direction', val)}
>
<SelectTrigger className="text-muted-foreground bg-background mt-2 w-full">
<SelectValue placeholder={_(msg`Select direction`)} />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="vertical">
<Trans>Vertical</Trans>
</SelectItem>
<SelectItem value="horizontal">
<Trans>Horizontal</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-row items-center gap-x-4"> <div className="flex flex-row items-center gap-x-4">
<div className="flex w-2/3 flex-col"> <div className="flex w-2/3 flex-col">
<Label> <Label>