Thanks @Sean Wright. I had a look at the "Text with Label" component, but it didn't seem to be what I was after. Example of it rendered out:

I've submitted this to the roadmap as a feature request, but for now I've managed to achieve a similar effect with a custom form component.

Can be configured like so:

And looks like this rendered in the content tab:

Here's the relevant code files, in case it helps someone else.
FormFieldGroup.ts
export type FormFieldGroup = {
index: number;
element: HTMLElement;
}
FormFieldGrouping.scss
.cms-form-field-grouping {
.cms-form-field-grouping__fields {
overflow: hidden;
max-height: 1000px;
transition: max-height ease-in-out 0.3s;
& > div {
margin-top: var(--kxp-stack-component-spacing-top);
}
}
}
.cms-form-field-grouping--collapsed {
max-height: 0;
display: none;
}
FormFieldGroupingClientComponentProperties.cs
namespace Redacted.Admin.UIFormComponents.FormFieldGrouping;
using Kentico.Xperience.Admin.Base.Forms;
/// <summary>
/// CMS Help Text client properties, passed to react app.
/// </summary>
public class FormFieldGroupingClientComponentProperties : FormComponentClientProperties<string>
{
/// <summary>
/// Gets or sets the group name.
/// </summary>
public string GroupName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether the grouping should display as collapsed by default.
/// </summary>
public bool Collapsed { get; set; }
}
FormFieldGroupingFormComponent.cs
using Kentico.Xperience.Admin.Base.Forms;
using Redacted.Admin.UIFormComponents.FormFieldGrouping;
[assembly: RegisterFormComponent("Redacted.Admin.UIFormComponents.FormFieldGrouping.FormFieldGroupingFormComponent", typeof(FormFieldGroupingFormComponent), "Custom - Form Field Grouping")]
namespace Redacted.Admin.UIFormComponents.FormFieldGrouping;
/// <summary>
/// CMS Help Text Form Component.
/// </summary>
public class FormFieldGroupingFormComponent : FormComponent<FormFieldGroupingFormComponentProperties, FormFieldGroupingClientComponentProperties, string>
{
/// <summary>
/// Gets the form component client name.
/// </summary>
public override string ClientComponentName => "@redacted/admin/FormFieldGrouping";
/// <summary>
/// Configures form component client properties.
/// </summary>
/// <param name="clientProperties">Client properties.</param>
/// <returns>Task.</returns>
protected override async Task ConfigureClientProperties(FormFieldGroupingClientComponentProperties clientProperties)
{
// Pass props to client app
clientProperties.GroupName = this.Properties.GroupName;
clientProperties.Collapsed = this.Properties.Collapsed;
await base.ConfigureClientProperties(clientProperties);
}
}
FormFieldGroupingFormComponent.tsx
import React, { useEffect, useRef, useState } from 'react';
import './FormFieldGrouping.scss';
import { FormFieldGroup } from './FormFieldGroup';
import { FormFieldGroupingFormComponentProps } from './FormFieldGroupingFormComponentProps';
import { GroupToggle } from './group-toggle/GroupToggle';
export const FormFieldGroupingFormComponent: React.FC<FormFieldGroupingFormComponentProps> = (props) => {
const elementRef = useRef<HTMLDivElement | null>(null);
const [collapsed, setCollapsed] = useState<boolean>(props.collapsed);
const instanceSlug = `cms-form-grouping-${props.guid}`;
const handleGroupToggle = () => {
setCollapsed((prevCollapsed) => !prevCollapsed);
};
useEffect(() => {
if (elementRef.current !== null) {
setGroupVisibility(instanceSlug, collapsed, elementRef.current);
}
});
return (
<div ref={elementRef} className="cms-form-field-grouping">
<GroupToggle groupName={props.groupName} collapsed={collapsed} onClick={handleGroupToggle} />
<div className={`cms-form-field-grouping__fields ${collapsed ? 'collapsed' : ''}`} />
</div>
);
};
const setGroupVisibility = (instanceSlug: string, collapsed: boolean, rootElement?: HTMLElement): void => {
if (rootElement) {
const currentFieldContainer = rootElement.parentElement;
const rootListing = currentFieldContainer?.parentElement;
if (!currentFieldContainer || !rootListing) {
return;
}
currentFieldContainer.classList.add(instanceSlug);
const fields = rootListing.querySelectorAll(':scope > div');
if (!fields) {
return;
}
const currentIndex = getCurrentGroupIndex(currentFieldContainer, fields);
const formGroups = getFormGroups(currentIndex, fields);
let startElement: HTMLElement | undefined;
let endElement: HTMLElement | undefined;
if (formGroups.length === 2) {
startElement = formGroups[0].element;
endElement = formGroups[1].element;
} else if (formGroups.length === 1) {
startElement = formGroups[0].element;
}
setGroupedFieldsVisibility(collapsed, Array.from(fields) as HTMLElement[], startElement, endElement);
}
};
const getCurrentGroupIndex = (currentGroup: HTMLElement, allFields: NodeListOf<Element>): number => {
const fieldsArray = Array.from(allFields) as HTMLElement[];
return fieldsArray.indexOf(currentGroup);
};
const getFormGroups = (currentIndex: number, allFields: NodeListOf<Element>): Array<FormFieldGroup> => {
const groups = [] as Array<FormFieldGroup>;
for (let i = currentIndex; i < allFields.length; i++) {
let group = allFields[i] as HTMLElement;
let isGrouping = (group.querySelector('.cms-form-field-grouping') != null) as boolean;
if (isGrouping) {
groups.push({ index: i, element: group });
}
if (groups.length == 2) {
return groups;
}
}
return groups;
};
const setGroupedFieldsVisibility = (
collapsed: boolean,
childFields: HTMLElement[],
startElement: HTMLElement | undefined,
endElement?: HTMLElement | undefined
): void => {
let startIndex = -1;
let endIndex = childFields.length;
if (startElement) {
startIndex = childFields.indexOf(startElement);
}
if (endElement) {
endIndex = childFields.indexOf(endElement);
}
for (let i = startIndex + 1; i <= endIndex - 1; i++) {
const element = childFields[i];
if (!element.parentNode) {
continue;
}
if (collapsed) {
element.classList.add('cms-form-field-grouping--collapsed')
} else {
element.classList.remove('cms-form-field-grouping--collapsed')
}
}
};
FormFieldGroupingFormComponentProperties.cs
namespace Redacted.Admin.UIFormComponents.FormFieldGrouping;
using System.ComponentModel.DataAnnotations;
using Kentico.Xperience.Admin.Base.FormAnnotations;
using Kentico.Xperience.Admin.Base.Forms;
/// <summary>
/// Form Field Grouping backend form component properties.
/// </summary>
public class FormFieldGroupingFormComponentProperties : FormComponentProperties
{
/// <summary>
/// Gets or sets the group name.
/// </summary>
[Required]
[TextInputComponent(Label = "Group name", ExplanationText = "Give the group a relevant title.", Order = 1)]
public string GroupName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether the grouping should display as collapsed by default.
/// </summary>
[CheckBoxComponent(Label = "Collapsed", ExplanationText = "The group starts out in a collapsed state.", Order = 2)]
public bool Collapsed { get; set; }
}
FormFieldGroupingFormComponentProps.tsx
import { FormComponentProps } from '@kentico/xperience-admin-base';
export interface FormFieldGroupingFormComponentProps extends FormComponentProps {
groupName: string;
collapsed: boolean;
}
GroupToggle.scss
.cms-group-toggle {
display: flex;
column-gap: 8px;
& > h2 {
font-family: "GT Walsheim", sans-serif;
font-weight: 700;
font-size: 16px;
line-height: 24px;
color: var(--color-text-default-on-light);
margin: 0;
padding: 0;
}
.cms-group-toggle__btn-wrap {
display: inline-flex;
width: fit-content;
height: fit-content;
border-radius: 5000px;
cursor: pointer;
button {
cursor: pointer;
width: 24px;
height: 24px;
background: rgba(0, 0, 0, 0);
border: none;
padding: 0 4px;
border-radius: 4px;
font-family: "GT Walsheim", sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 13.2px;
color: var(--color-text-default-on-light);
> div {
font-size: 16px;
}
&:hover {
background: var(--color-hover);
color: var(--color-text-default-on-light);
border: none;
}
}
}
}
GroupToggle.tsx
import React, { ReactElement } from 'react';
import './GroupToggle.scss';
import { GroupToggleProps } from './GroupToggleProps';
export const GroupToggle = (props: GroupToggleProps) => {
return (
<div className="cms-group-toggle">
<h2>{props.groupName}</h2>
<div className="cms-group-toggle__btn-wrap" onClick={props.onClick}>
<button
type="button"
aria-label="button"
data-testid="form-category-button-Page Base">
{renderChevron(props.collapsed)}
</button>
</div>
</div>
);
};
const renderChevron = (collapsed: boolean): ReactElement => {
if (collapsed) {
return <div data-testid="xp-chevron-down">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" role="img">
<path fillRule="evenodd" clipRule="evenodd" d="M14.92 5.724a.504.504 0 0 1-.14.696l-6.5 4.996a.498.498 0 0 1-.554 0l-6.5-4.996a.504.504 0 0 1-.138-.696.499.499 0 0 1 .693-.14l6.222 4.81 6.223-4.81a.499.499 0 0 1 .693.14Z" fill="currentColor"></path>
</svg>
</div>;
}
return <div data-testid="xp-chevron-up">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" role="img">
<path fillRule="evenodd" clipRule="evenodd" d="M7.726 4.584a.5.5 0 0 1 .555 0l6.5 5a.5.5 0 0 1-.555.833L8.004 5.6 1.78 10.417a.5.5 0 1 1-.555-.832l6.5-5.001Z" fill="currentColor"></path>
</svg>
</div>;
}
GroupToggleProps.tsx
import { MouseEventHandler } from 'React';
export interface GroupToggleProps {
groupName: string;
collapsed: boolean;
onClick: MouseEventHandler<HTMLDivElement>;
}