diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 76fd10351..95fefe557 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -264,7 +264,7 @@ type Admin = { position?: 'sidebar' readOnly?: boolean style?: CSSProperties - width?: string + width?: CSSProperties['width'] } export type AdminClient = { @@ -296,8 +296,8 @@ export type AdminClient = { hidden?: boolean position?: 'sidebar' readOnly?: boolean - style?: CSSProperties - width?: string + style?: { '--field-width'?: CSSProperties['width'] } & CSSProperties + width?: CSSProperties['width'] } export type Labels = { @@ -802,7 +802,7 @@ export type UIField = { */ disableListColumn?: boolean position?: string - width?: string + width?: CSSProperties['width'] } /** Extension point to add your custom data. Server only. */ custom?: Record diff --git a/packages/ui/src/fields/Collapsible/index.tsx b/packages/ui/src/fields/Collapsible/index.tsx index 309355e25..fc78e54dc 100644 --- a/packages/ui/src/fields/Collapsible/index.tsx +++ b/packages/ui/src/fields/Collapsible/index.tsx @@ -1,5 +1,5 @@ 'use client' -import type { CollapsibleFieldClientComponent, DocumentPreferences } from 'payload' +import type { AdminClient, CollapsibleFieldClientComponent, DocumentPreferences } from 'payload' import React, { Fragment, useCallback, useEffect, useState } from 'react' @@ -116,6 +116,11 @@ const CollapsibleFieldComponent: CollapsibleFieldClientComponent = (props) => { const disabled = readOnlyFromProps || readOnlyFromContext || formProcessing || formInitializing + const style: AdminClient['style'] = { + ...field.admin?.style, + '--field-width': field.admin.width, + } + return ( @@ -129,6 +134,7 @@ const CollapsibleFieldComponent: CollapsibleFieldClientComponent = (props) => { .filter(Boolean) .join(' ')} id={`field-${fieldPreferencesKey}${path ? `-${path.replace(/\./g, '__')}` : ''}`} + style={style} > * { - flex-grow: 1; + flex: 0 1 var(--field-width); + } + + // If there is more than one child, add inline-margins to space them out. + &:has(> *:nth-child(2)) { + margin-inline: calc(var(--base) / -4); // add negative margin to counteract the gap. + + > * { + flex: 0 1 calc(var(--field-width) - var(--base) * 0.5); + margin-inline: calc(var(--base) / 4); + } } } diff --git a/packages/ui/src/fields/Select/Input.tsx b/packages/ui/src/fields/Select/Input.tsx index eeaa3858f..c7e98e1fc 100644 --- a/packages/ui/src/fields/Select/Input.tsx +++ b/packages/ui/src/fields/Select/Input.tsx @@ -47,7 +47,7 @@ export type SelectInputProps = { readonly showError?: boolean readonly style?: React.CSSProperties readonly value?: string | string[] - readonly width?: string + readonly width?: React.CSSProperties['width'] } export const SelectInput: React.FC = (props) => { diff --git a/packages/ui/src/fields/Text/types.ts b/packages/ui/src/fields/Text/types.ts index 53475b463..b25672a34 100644 --- a/packages/ui/src/fields/Text/types.ts +++ b/packages/ui/src/fields/Text/types.ts @@ -41,5 +41,5 @@ export type TextInputProps = { readonly style?: React.CSSProperties readonly value?: string readonly valueToRender?: Option[] - readonly width?: string + readonly width?: React.CSSProperties['width'] } & SharedTextFieldProps diff --git a/packages/ui/src/fields/Textarea/types.ts b/packages/ui/src/fields/Textarea/types.ts index 2453f94f4..a764f058b 100644 --- a/packages/ui/src/fields/Textarea/types.ts +++ b/packages/ui/src/fields/Textarea/types.ts @@ -38,5 +38,5 @@ export type TextAreaInputProps = { readonly style?: React.CSSProperties readonly value?: string readonly valueToRender?: string - readonly width?: string + readonly width?: React.CSSProperties['width'] } diff --git a/packages/ui/src/fields/Upload/Input.tsx b/packages/ui/src/fields/Upload/Input.tsx index e1ff8cace..843a48c64 100644 --- a/packages/ui/src/fields/Upload/Input.tsx +++ b/packages/ui/src/fields/Upload/Input.tsx @@ -75,7 +75,7 @@ export type UploadInputProps = { readonly showError?: boolean readonly style?: React.CSSProperties readonly value?: (number | string)[] | (number | string) - readonly width?: string + readonly width?: React.CSSProperties['width'] } export function UploadInput(props: UploadInputProps) { diff --git a/packages/ui/src/providers/Config/createClientConfig/fields.tsx b/packages/ui/src/providers/Config/createClientConfig/fields.tsx index 41ca91ccf..9cc28728d 100644 --- a/packages/ui/src/providers/Config/createClientConfig/fields.tsx +++ b/packages/ui/src/providers/Config/createClientConfig/fields.tsx @@ -115,6 +115,22 @@ export const createClientField = ({ const serverProps = { serverProps: { field: incomingField } } + if ('admin' in incomingField && 'width' in incomingField.admin) { + clientField.admin.style = { + ...clientField.admin.style, + '--field-width': clientField.admin.width, + width: undefined, // avoid needlessly adding this to the element's style attribute + } + } else { + if (!(clientField.admin instanceof Object)) { + clientField.admin = {} + } + if (!(clientField.admin.style instanceof Object)) { + clientField.admin.style = {} + } + clientField.admin.style.flex = '1 1 auto' + } + switch (incomingField.type) { case 'array': case 'group': @@ -521,7 +537,7 @@ export const createClientFields = ({ }) if (newField) { - newClientFields.push({ ...newField }) + newClientFields.push(newField) } } diff --git a/test/fields/collections/Row/index.ts b/test/fields/collections/Row/index.ts index 0416c9cc8..db9aced88 100644 --- a/test/fields/collections/Row/index.ts +++ b/test/fields/collections/Row/index.ts @@ -51,7 +51,36 @@ const RowFields: CollectionConfig = { type: 'row', fields: [ { - label: 'Collapsible within a row', + name: 'field_with_width_30_percent', + label: 'Field with 30% width', + type: 'text', + admin: { + width: '30%', + }, + }, + { + name: 'field_with_width_60_percent', + label: 'Field with 60% width', + type: 'text', + admin: { + width: '60%', + }, + }, + { + name: 'field_with_width_20_percent', + label: 'Field with 20% width', + type: 'text', + admin: { + width: '20%', + }, + }, + ], + }, + { + type: 'row', + fields: [ + { + label: 'Collapsible 30% width within a row', type: 'collapsible', fields: [ { @@ -60,6 +89,9 @@ const RowFields: CollectionConfig = { type: 'text', }, ], + admin: { + width: '30%', + }, }, { label: 'Collapsible within a row', @@ -74,6 +106,37 @@ const RowFields: CollectionConfig = { }, ], }, + { + type: 'row', + fields: [ + { + label: 'Explicit 20% width within a row (A)', + type: 'text', + name: 'field_20_percent_width_within_row_a', + admin: { + width: '20%', + }, + }, + { + label: 'No set width within a row (B)', + type: 'text', + name: 'no_set_width_within_row_b', + }, + { + label: 'No set width within a row (C)', + type: 'text', + name: 'no_set_width_within_row_c', + }, + { + label: 'Explicit 20% width within a row (D)', + type: 'text', + name: 'field_20_percent_width_within_row_d', + admin: { + width: '20%', + }, + }, + ], + }, ], } diff --git a/test/fields/e2e.spec.ts b/test/fields/e2e.spec.ts index 5812fb6c2..b3a297c6d 100644 --- a/test/fields/e2e.spec.ts +++ b/test/fields/e2e.spec.ts @@ -1,4 +1,4 @@ -import type { Page } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' import { expect, test } from '@playwright/test' import path from 'path' @@ -374,20 +374,89 @@ describe('fields', () => { test('should render row fields inline and with explicit widths', async () => { await page.goto(url.create) const fieldA = page.locator('input#field-field_with_width_a') - await expect(fieldA).toBeVisible() const fieldB = page.locator('input#field-field_with_width_b') + + await expect(fieldA).toBeVisible() await expect(fieldB).toBeVisible() + const fieldABox = await fieldA.boundingBox() const fieldBBox = await fieldB.boundingBox() - // Check that the top value of the fields are the same - // Give it some wiggle room of like 2px to account for differences in rendering - const tolerance = 2 - expect(fieldABox.y).toBeLessThanOrEqual(fieldBBox.y + tolerance) + await expect(() => { + expect(fieldABox.y).toEqual(fieldBBox.y) + expect(fieldABox.width).toEqual(fieldBBox.width) + }).toPass() - // Check that the widths of the fields are the same - const difference = Math.abs(fieldABox.width - fieldBBox.width) - expect(difference).toBeLessThanOrEqual(tolerance) + const field_30_percent = page.locator( + '.field-type.text:has(input#field-field_with_width_30_percent)', + ) + const field_60_percent = page.locator( + '.field-type.text:has(input#field-field_with_width_60_percent)', + ) + const field_20_percent = page.locator( + '.field-type.text:has(input#field-field_with_width_20_percent)', + ) + const collapsible_30_percent = page.locator( + '.collapsible-field:has(#field-field_within_collapsible_a)', + ) + + const field_20_percent_width_within_row_a = page.locator( + '.field-type.text:has(input#field-field_20_percent_width_within_row_a)', + ) + const field_no_set_width_within_row_b = page.locator( + '.field-type.text:has(input#field-no_set_width_within_row_b)', + ) + const field_no_set_width_within_row_c = page.locator( + '.field-type.text:has(input#field-no_set_width_within_row_c)', + ) + const field_20_percent_width_within_row_d = page.locator( + '.field-type.text:has(input#field-field_20_percent_width_within_row_d)', + ) + + await expect(field_30_percent).toBeVisible() + await expect(field_60_percent).toBeVisible() + await expect(field_20_percent).toBeVisible() + await expect(collapsible_30_percent).toBeVisible() + await expect(field_20_percent_width_within_row_a).toBeVisible() + await expect(field_no_set_width_within_row_b).toBeVisible() + await expect(field_no_set_width_within_row_c).toBeVisible() + await expect(field_20_percent_width_within_row_d).toBeVisible() + + const field_30_boundingBox = await field_30_percent.boundingBox() + const field_60_boundingBox = await field_60_percent.boundingBox() + const field_20_boundingBox = await field_20_percent.boundingBox() + const collapsible_30_boundingBox = await collapsible_30_percent.boundingBox() + const field_20_percent_width_within_row_a_box = + await field_20_percent_width_within_row_a.boundingBox() + const field_no_set_width_within_row_b_box = + await field_no_set_width_within_row_b.boundingBox() + const field_no_set_width_within_row_c_box = + await field_no_set_width_within_row_c.boundingBox() + const field_20_percent_width_within_row_d_box = + await field_20_percent_width_within_row_d.boundingBox() + + await expect(() => { + expect(field_30_boundingBox.y).toEqual(field_60_boundingBox.y) + expect(field_30_boundingBox.x).toEqual(field_20_boundingBox.x) + expect(field_30_boundingBox.y).not.toEqual(field_20_boundingBox.y) + expect(field_30_boundingBox.height).toEqual(field_60_boundingBox.height) + expect(collapsible_30_boundingBox.width).toEqual(field_30_boundingBox.width) + + expect(field_20_percent_width_within_row_a_box.y).toEqual( + field_no_set_width_within_row_b_box.y, + ) + expect(field_no_set_width_within_row_b_box.y).toEqual(field_no_set_width_within_row_c_box.y) + expect(field_no_set_width_within_row_c_box.y).toEqual( + field_20_percent_width_within_row_d_box.y, + ) + + expect(field_20_percent_width_within_row_a_box.width).toEqual( + field_20_percent_width_within_row_d_box.width, + ) + expect(field_no_set_width_within_row_b_box.width).toEqual( + field_no_set_width_within_row_c_box.width, + ) + }).toPass() }) test('should render nested row fields in the correct position', async () => { @@ -402,15 +471,11 @@ describe('fields', () => { const fieldABox = await fieldA.boundingBox() const fieldBBox = await fieldB.boundingBox() - // Check that the top value of the fields are the same - // Give it some wiggle room of like 2px to account for differences in rendering - const tolerance = 2 - expect(fieldABox.y).toBeLessThanOrEqual(fieldBBox.y + tolerance) - - // Check that the widths of the fields are the same - const collapsibleDifference = Math.abs(fieldABox.width - fieldBBox.width) - - expect(collapsibleDifference).toBeLessThanOrEqual(tolerance) + await expect(() => { + // Check that the top value of the fields are the same + expect(fieldABox.y).toEqual(fieldBBox.y) + expect(fieldABox.height).toEqual(fieldBBox.height) + }).toPass() }) })