Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e8ba63b
Add an actions slot to craft-field
brianjhanson Aug 17, 2026
28d33c8
Move field action menus and copy buttons to the actions slot
brianjhanson Aug 17, 2026
5bb3d3c
Only apply the deprecated labelExtra when it is set
brianjhanson Aug 17, 2026
92aa1ad
Add Field actions to the form builder
brianjhanson Aug 17, 2026
4da3f36
Let condition builder controls carry field layouts
brianjhanson Aug 17, 2026
9705913
Add a field select form control
brianjhanson Aug 17, 2026
27a26cd
Move field layout component settings to the form builder
brianjhanson Aug 17, 2026
c71d933
Render field layout component settings with the form renderer
brianjhanson Aug 17, 2026
7dc513c
Document field layout component settings forms
brianjhanson Aug 17, 2026
fb33213
Fix field layout component settings slideout rendering and refresh
brianjhanson Aug 17, 2026
2f7eaf8
Keep the field layout settings slideout within the viewport
brianjhanson Aug 17, 2026
44b330f
Fix action menu invoker display
brianjhanson Aug 17, 2026
716d577
Open field layout component settings in a Vue slideout
brianjhanson Aug 17, 2026
e18edbf
Update slideout label
brianjhanson Aug 17, 2026
f4074b8
Wrap slot with field-group
brianjhanson Aug 17, 2026
521656a
Formatting
brianjhanson Aug 17, 2026
12431b3
Fix default styling of action menu
brianjhanson Aug 17, 2026
e04c03c
Remove btn class on skip-link
brianjhanson Aug 17, 2026
2b12185
Fix default styling on reorder button
brianjhanson Aug 17, 2026
ee7a49c
lock update
brianjhanson Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,23 @@
- Removed support for the Debug Toolbar. [Laravel Debugbar](https://laraveldebugbar.com) can be used instead. ([#18812](https://github.com/craftcms/cms/pull/18812))

### Extensibility
- Added `CraftCms\Cms\FieldLayout\FieldLayoutComponent::settingsForm()`.
- Added `CraftCms\Cms\FieldLayout\FieldLayoutComponent::settingsNodes()`.
- Added `CraftCms\Cms\FieldLayout\FieldLayoutComponent::conditionalSettingsNodes()`.
- Added `CraftCms\Cms\Form\Nodes\Action`.
- Added `CraftCms\Cms\Form\Nodes\Field::actions()`.
- Added `CraftCms\Cms\Form\Controls\Checkbox`.
- Added `CraftCms\Cms\Form\Controls\FieldSelect`.
- Added `CraftCms\Cms\Form\Controls\ConditionBuilder::fieldLayouts()`.
- Added `CraftCms\Cms\Cp\Components\Field::actions()`.
- Added `CraftCms\Cms\Cp\FormFields::fieldSelectHtml()`.
- Added the `actions` slot to `<craft-field>`.
- Deprecated `CraftCms\Cms\Cp\Components\Field::labelExtra()`. `actions()` should be used instead.
- Deprecated the `labelExtra` field config option. `actions` should be used instead.
- Deprecated `<craft-field>`’s `label-extra` slot. The `actions` slot should be used instead.
- Removed `CraftCms\Cms\FieldLayout\FieldLayoutComponent::settingsHtml()`.
- Removed `CraftCms\Cms\FieldLayout\FieldLayoutComponent::renderSettingsHtml()`.
- Removed `CraftCms\Cms\FieldLayout\FieldLayoutComponent::conditionalSettingsHtml()`.
- Added `CraftCms\Cms\Support\Arr`.
- Added `CraftCms\Cms\Support\DateTimeHelper`.
- Added `CraftCms\Cms\Support\File`.
Expand Down
51 changes: 51 additions & 0 deletions docs/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,38 @@ Return zero or one root Node. It may contain children or a composite Control. Th
mode. Listen for `FieldLayoutFormResolving` to add, remove, or reorder typed Nodes after compilation; do not mutate
rendered HTML or persisted layout data.

### FieldLayout component settings

Field layout components — tabs and layout elements — describe the form shown in the designer's settings slideout by
implementing `settingsNodes()` instead of `settingsHtml()`:

```php
use CraftCms\Cms\Form\Controls\Text;
use CraftCms\Cms\Form\FormContext;
use CraftCms\Cms\Form\Nodes\Field;

protected function settingsNodes(FormContext $context): array
{
return [
Field::make(t('Heading'), Text::make('heading')->value($this->heading)),
];
}
```

Return a list of Nodes with paths relative to the component's config, so a Control at `heading` posts back as the
component's `heading` setting. `FieldLayoutComponent::settingsForm()` is `final`: it composes `settingsNodes()` and
`conditionalSettingsNodes()`, separating them with a `Separator` Node when both are present, and returns `null` when
neither produces a Node.

`conditionalSettingsNodes()` supplies the visibility condition builders. Override it to append further condition
groups — `CustomField` adds its editability conditions this way — and use `conditionGroupNode()` to build a group
with the standard user/element condition pair.

The settings scope is `settings`, and the form is refreshable: a `discrete` change posts back to
`fields/refresh-layout-component-settings`, which rebuilds the component from the posted values and re-resolves the
form. Use that instead of client-side scripting when one setting should change another's state — hiding a field's
label, for instance, disables its label Control on the next refresh.

## Custom Nodes and Controls

Use a core Node or Control when one already has the required value shape and behavior. A plugin-specific type is needed
Expand All @@ -143,6 +175,25 @@ A custom Node implements `CraftCms\Cms\Form\Contracts\Node`. A custom Control ca
Container Nodes can extend `CraftCms\Cms\Form\Nodes\Container`, which provides stable UID storage, ordered children,
fluent and conditional child addition, and the standard no-Control behavior.

### Field actions

A `Field` Node can carry action Nodes in its heading, rendered into `<craft-field>`'s `actions` slot — a hide-label
toggle, a copy-value button, a settings menu:

```php
use CraftCms\Cms\Form\Controls\Checkbox;
use CraftCms\Cms\Form\Nodes\Action;

Field::make(t('Label'), Text::make('label'))
->actions(Action::make(
Checkbox::make('labelHidden')->label(t('Hide')),
));
```

Actions are resolved as ordinary child Nodes, so each one's Control gets its own path, value binding, mode, and error
binding — an action is a real posting Control, not decoration. `Action` renders its Control without the surrounding
field chrome.

Register the PHP types during plugin boot:

```php
Expand Down
33 changes: 31 additions & 2 deletions docs/slideouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,33 @@ Fetches `href` as an Inertia page and mounts it in a panel. Returns the `Slideou

Pass `opener` whenever you have it — focus restoration and nesting both depend on it.

### `openSlideoutWith(component, props?, options?)`

Opens a panel around a component you supply, with no fetch. Same `options` as
`openSlideout`, and returns the `SlideoutInstance` synchronously — or `null` when
the user declined to discard a panel it would have replaced.

Almost every screen lives at a URL and should use `openSlideout`. This is for the
ones that don't: the field layout designer builds its component settings by
POSTing the layout being edited, which is unsaved client state with nothing to
GET.

```ts
openSlideoutWith(LayoutComponentSettings, {payload, apply}, {opener: button});
```

The panel is otherwise ordinary — stacking, the shade, focus, Escape and the
unsaved-changes prompt all behave the same. `reload()` is a no-op, since there is
nothing to re-fetch. The component still renders inside `AppLayout`, so it
configures the shell with `useAppLayout()` like any page; pass a `form` to get a
Save button and an accurate dirty check.

### Other exports

```ts
import {
openSlideout,
openSlideoutWith,
closeSlideout, // (id: string) => void
closeAllSlideouts,
useSlideout,
Expand Down Expand Up @@ -403,8 +425,15 @@ result props come back, and `rowSelection` is keyed by element id and lives outs
## Coexisting with the legacy stack

The legacy `Craft.Slideout` (and its `CpScreenSlideout` / `ElementEditorSlideout` subclasses) is
still very much alive — the field layout designer, matrix, component select and the nested element
manager all open one. So both kinds can be on screen at once: a Vue panel opened over a legacy
still very much alive — matrix, component select and the nested element manager all open one, and
the field layout designer falls back to it.

> The designer's component settings use `openSlideoutWith()` when the Vue stack is available, and
> the legacy slideout otherwise. It has to: `SlideoutHost` is only mounted by the Inertia CP shell,
> and the designer is also reachable from legacy-stack screens through the `fieldLayoutDesigner()`
> Twig function. `canUseVueSlideout()` in
> `resources/js/modules/field-layout-designer/settings-slideout.ts` decides, keying off whether the
> shell registered its globals. So both kinds can be on screen at once: a Vue panel opened over a legacy
slideout, or a legacy element editor opened from inside a Vue panel.

Everything that needs to see *all* open panels rather than one stack's own lives in
Expand Down
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -522,8 +522,8 @@ export default class CraftActionMenu extends CraftPopover {
invoker.setAttribute('type', 'button');
invoker.setAttribute('icon', '');
invoker.setAttribute('size', 'small');
invoker.setAttribute('variant', 'inherit');
invoker.setAttribute('appearance', 'plain');
invoker.setAttribute('inherit', 'true');
invoker.setAttribute('variant', 'plain');
this._generatedInvoker = invoker;
this.appendChild(invoker);
}
Expand Down Expand Up @@ -574,9 +574,10 @@ export default class CraftActionMenu extends CraftPopover {
// Once Lion's overlay controller is set up, the slotted content node is
// moved inside its content wrapper (a <dialog>) and loses its `slot`
// attribute — so prefer the node Lion has already resolved and cached.
if (this._cachedOverlayContentNode) {
return this._cachedOverlayContentNode;
if (this._overlayContentNode) {
return this._overlayContentNode;
}

return (
(Array.from(this.children).find((child) => child.slot === 'content') as
| HTMLElement
Expand Down
12 changes: 12 additions & 0 deletions packages/craftcms-ui/src/components/field/field.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,18 @@ export const LabelExtra: Story = {
`,
};

export const Actions: Story = {
render: () => html`
<craft-field label="Field label">
<input slot="input" type="text" />
<craft-checkbox slot="actions" label="Hide"></craft-checkbox>
<craft-button slot="actions" icon="clipboard" variant="subtle">
Copy value
</craft-button>
</craft-field>
`,
};

export const WithCraftInput: Story = {
render: () => html`
<craft-field
Expand Down
7 changes: 7 additions & 0 deletions packages/craftcms-ui/src/components/field/field.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ export default css`
flex: 1 0 0;
}

.field-actions {
display: flex;
flex-wrap: nowrap;
gap: var(--c-spacing-2xs, 0.125rem);
align-items: center;
}

::slotted([slot='label']) {
display: flex;
flex-wrap: wrap;
Expand Down
50 changes: 50 additions & 0 deletions packages/craftcms-ui/src/components/field/field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,56 @@ describe('craft-field label extras', () => {
});
});

describe('craft-field actions', () => {
it('renders a flex-grow spacer before slotted actions', async () => {
const element = await createField(
{label: 'My field'},
'<input slot="input" type="text"><button slot="actions">Hide</button>'
);

const heading = element.shadowRoot!.querySelector('.heading')!;
const spacer = heading.querySelector('.flex-grow');
const slot = heading.querySelector('slot[name="actions"]');
expect(spacer).not.toBeNull();
expect(slot).not.toBeNull();
expect(
spacer!.compareDocumentPosition(slot!) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
});

it('groups slotted actions', async () => {
const element = await createField(
{label: 'My field'},
'<input slot="input" type="text"><button slot="actions">Hide</button>'
);

const group = element.shadowRoot!.querySelector('.field-actions')!;
expect(group).not.toBeNull();
expect(group.getAttribute('role')).toBe('group');
expect(group.querySelector('slot[name="actions"]')).not.toBeNull();
});

it('renders actions after label extras', async () => {
const element = await createField(
{label: 'My field'},
'<input slot="input" type="text"><code slot="label-extra">handle</code><button slot="actions">Hide</button>'
);

const heading = element.shadowRoot!.querySelector('.heading')!;
const labelExtra = heading.querySelector('slot[name="label-extra"]')!;
const actions = heading.querySelector('slot[name="actions"]')!;
expect(
labelExtra.compareDocumentPosition(actions) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
});

it('renders no action group without actions', async () => {
const element = await createField({label: 'My field'});
expect(element.shadowRoot!.querySelector('.field-actions')).toBeNull();
});
});

describe('craft-field disabled state', () => {
it('adds the disabled class to the input container only', async () => {
const element = await createField({label: 'My field', disabled: ''});
Expand Down
30 changes: 23 additions & 7 deletions packages/craftcms-ui/src/components/field/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ type FormControlTarget = HTMLElement & {
* @slot feedback - Validation errors (e.g. an error list).
* @slot tip - Tip notice content, rendered inside an info callout.
* @slot warning - Warning notice content, rendered inside a warning callout.
* @slot label-extra - Extra heading content (handle-copy buttons, action
* menus), rendered after a flex-grow spacer.
* @slot label-extra - Extra heading content, rendered after a flex-grow spacer.
* @deprecated Use `actions` instead.
* @slot actions - Field-level actions (hide-label toggles, copy-value buttons,
* field settings menus), rendered as a group at the end of the heading.
*/
export default class CraftField extends FormControlMixin(LitElement) {
static override get styles() {
Expand Down Expand Up @@ -291,21 +293,35 @@ export default class CraftField extends FormControlMixin(LitElement) {
}

/**
* The field heading: label, read-only badge, flex-grow spacer and label
* extras, mirroring `.field > .heading` in the Blade wrapper.
* The field heading: label, read-only badge, flex-grow spacer, label extras
* and actions, mirroring `.field > .heading` in the Blade wrapper.
*/
protected override _labelTemplate() {
const hasActions = this.__hasLightChild('actions');

return html`
<div class="heading form-field__label">
<slot name="heading-prefix"></slot>
<slot name="label"></slot>
${this.readOnly
? html`<span class="read-only-badge">${t('Read Only')}</span>`
: nothing}
${this.__hasLightChild('label-extra')
${this.__hasLightChild('label-extra') || hasActions
? html`<div class="flex-grow"></div>`
: nothing}
<slot name="label-extra"></slot>
${hasActions
? html`
<div
class="field-actions"
part="actions"
role="group"
aria-label=${t('Field actions')}
>
<slot name="actions"></slot>
</div>
`
: html`<slot name="actions"></slot>`}
<slot name="heading-suffix"></slot>
</div>
`;
Expand Down Expand Up @@ -394,8 +410,8 @@ export default class CraftField extends FormControlMixin(LitElement) {
this.__syncLabelDecorations();
this.__syncHasMaxlength();
this.__syncControlWidth();
// Conditional templates (tip/warning callouts, label-extra spacer) depend
// on light DOM children.
// Conditional templates (tip/warning callouts, heading spacer, action
// group) depend on light DOM children.
this.requestUpdate();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default class CraftReorderButton extends LitElement {
@property({reflect: true}) orientation: ReorderOrientation = 'vertical';

/** Theme variant forwarded to the underlying invoker button. */
@property({reflect: true}) variant: string = 'neutral';
@property({reflect: true}) variant: string = 'plain';

/**
* Disables the button: blocks pointer interaction (so it can't open the menu or
Expand Down Expand Up @@ -125,7 +125,6 @@ export default class CraftReorderButton extends LitElement {
type="button"
icon
size="small"
variant="plain"
variant="${this.variant}"
?disabled="${this.disabled}"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,8 @@ export default class CraftTextExpander extends LitElement {
return;
}

const label = this.#listbox.getAttribute('aria-label') ?? defaultListboxLabel;
const label =
this.#listbox.getAttribute('aria-label') ?? defaultListboxLabel;

this.#cancelPending();
this.#match = null;
Expand Down
13 changes: 11 additions & 2 deletions resources/css/fld.css
Original file line number Diff line number Diff line change
Expand Up @@ -370,9 +370,19 @@ body:not(.dragging) .fld-element {
}

/* Element settings (slideout) */

/* Never taller than the viewport, so the body scrolls rather than the slideout
growing past the window. */
.fld-element-settings {
max-height: 100dvh;
}

.fld-element-settings-body {
flex: 1;
margin-block: -24px 0;

/* Flex items default to min-height: auto, which refuses to shrink below the
content and stops `overflow` below from ever engaging. */
min-height: 0;

/* TODO: no token yet for --neg-padding */
margin-inline: var(--neg-padding);
Expand All @@ -393,7 +403,6 @@ body:not(.dragging) .fld-element {
display: flex;
gap: var(--c-spacing-sm);
flex-direction: row;
margin-block: 0 -24px;

/* TODO: no token yet for --neg-padding */
margin-inline: var(--neg-padding);
Expand Down
4 changes: 3 additions & 1 deletion resources/js/common/layouts/screens/SlideoutScreen.vue
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,9 @@

<CalloutReadOnly v-if="readOnly" />

<slot></slot>
<craft-field-group>
<slot></slot>
</craft-field-group>

<LayoutSlotOutlet name="content-footer">
<slot name="content-footer"></slot>
Expand Down
Loading
Loading