From 5b0dd8520b6012a51038ef724b99c6965829b94c Mon Sep 17 00:00:00 2001
From: Brian Hanson
Date: Wed, 17 Jun 2026 15:08:08 -0500
Subject: [PATCH 1/3] WIP Initial port of Entry type edit page
---
.../js/pages/settings/EntryTypesEdit.vue | 349 ++++++++++++++++++
.../Settings/EntryTypesController.php | 169 ++++-----
2 files changed, 414 insertions(+), 104 deletions(-)
create mode 100644 resources/js/pages/settings/EntryTypesEdit.vue
diff --git a/resources/js/pages/settings/EntryTypesEdit.vue b/resources/js/pages/settings/EntryTypesEdit.vue
new file mode 100644
index 00000000000..cae9e38d987
--- /dev/null
+++ b/resources/js/pages/settings/EntryTypesEdit.vue
@@ -0,0 +1,349 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('Icon') }}
+
+ {{
+ t(
+ 'The icon picker will be available here soon. The existing icon is preserved when saving.'
+ )
+ }}
+
+
+
+
+
+
{{ t('Color') }}
+
+ {{
+ t(
+ 'The color picker will be available here soon. The existing color is preserved when saving.'
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('Field Layout') }}
+
+ {{
+ t('Define the field layout for {type} of this type.', {
+ type: lowerTypeName,
+ })
+ }}
+
+
+
+
+ {{
+ t(
+ 'The field layout designer will be available here soon. Existing field layouts are preserved when saving.'
+ )
+ }}
+
+
+
+
+
+
+
+
diff --git a/src/Http/Controllers/Settings/EntryTypesController.php b/src/Http/Controllers/Settings/EntryTypesController.php
index 287c5793901..a02b41edc60 100644
--- a/src/Http/Controllers/Settings/EntryTypesController.php
+++ b/src/Http/Controllers/Settings/EntryTypesController.php
@@ -4,18 +4,13 @@
namespace CraftCms\Cms\Http\Controllers\Settings;
-use CraftCms\Cms\Component\Contracts\Iconic;
use CraftCms\Cms\Config\GeneralConfig;
-use CraftCms\Cms\Cp\Html\ContentHtml;
use CraftCms\Cms\Cp\Html\ElementHtml;
-use CraftCms\Cms\Cp\Icons;
use CraftCms\Cms\Entry\Data\EntryType;
use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Entry\EntryTypes;
use CraftCms\Cms\Entry\Models\EntryType as EntryTypeModel;
use CraftCms\Cms\Entry\Resources\EntryTypeResource;
-use CraftCms\Cms\Field\Contracts\ElementContainerFieldInterface;
-use CraftCms\Cms\Field\Contracts\FieldInterface;
use CraftCms\Cms\Field\Enums\TranslationMethod;
use CraftCms\Cms\Field\Fields;
use CraftCms\Cms\FieldLayout\FieldLayout;
@@ -24,11 +19,10 @@
use CraftCms\Cms\Http\Requests\TableRequest;
use CraftCms\Cms\Http\RespondsWithFlash;
use CraftCms\Cms\Http\Responses\CpScreenResponse;
-use CraftCms\Cms\Section\Data\Section;
use CraftCms\Cms\Shared\Enums\Color;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Facades\InputNamespace;
-use CraftCms\Cms\Support\Html;
+use CraftCms\Cms\Support\Facades\Sites;
use CraftCms\Cms\Support\Str;
use CraftCms\Cms\Support\Url;
use CraftCms\Cms\View\HtmlStack;
@@ -91,30 +85,12 @@ public function create(): CpScreenResponse
{
$entryType = new EntryType;
- $fieldLayout = $entryType->getFieldLayout();
-
- if ($entryType->hasTitleField && ! $fieldLayout->isFieldIncluded('title')) {
- $fieldLayout->prependElements([new EntryTitleField]);
- }
-
return new CpScreenResponse()
->title(t('Create a new entry type'))
->addCrumb(t('Settings'), 'settings')
->addCrumb(t('Entry Types'), 'settings/entry-types')
- ->contentTemplate('settings/entry-types/_edit.twig', [
- 'entryTypeId' => null,
- 'entryType' => $entryType,
- 'typeName' => Entry::displayName(),
- 'lowerTypeName' => Entry::lowerDisplayName(),
- 'readOnly' => $this->readOnly,
- ])
- ->action('entry-types/save')
->redirectUrl('settings/entry-types')
- ->addAltAction(t('Save and continue editing'), [
- 'redirect' => 'settings/entry-types/{id}',
- 'shortcut' => true,
- 'retainScroll' => true,
- ]);
+ ->inertiaPage('settings/EntryTypesEdit', $this->entryTypeProps($entryType, brandNew: true));
}
public function edit(Request $request, ?EntryTypeModel $entryType = null): CpScreenResponse
@@ -127,94 +103,79 @@ public function edit(Request $request, ?EntryTypeModel $entryType = null): CpScr
abort_if(is_null($entryTypeData), 404, 'Entry type not found');
- $fieldLayout = $entryTypeData->getFieldLayout();
+ return new CpScreenResponse()
+ ->editUrl($entryTypeData->getCpEditUrl())
+ ->title(trim($entryTypeData->name) ?: t('Edit Entry Type'))
+ ->addCrumb(t('Settings'), 'settings')
+ ->addCrumb(t('Entry Types'), 'settings/entry-types')
+ ->redirectUrl('settings/entry-types')
+ ->inertiaPage('settings/EntryTypesEdit', $this->entryTypeProps($entryTypeData, brandNew: false));
+ }
- if ($entryTypeData->hasTitleField) {
- // Ensure the Title field is present
+ /**
+ * Builds the Inertia props for the entry type edit/new screen.
+ */
+ private function entryTypeProps(EntryType $entryType, bool $brandNew): array
+ {
+ $fieldLayout = $entryType->getFieldLayout();
+
+ // Normalize the Title field's presence so the round-tripped config matches what gets saved back.
+ if ($entryType->hasTitleField) {
if (! $fieldLayout->isFieldIncluded('title')) {
$fieldLayout->prependElements([new EntryTitleField]);
}
} else {
- // Remove the title field
foreach ($fieldLayout->getTabs() as $tab) {
- $elements = array_filter($tab->getElements(),
- fn (FieldLayoutElement $element) => ! $element instanceof EntryTitleField);
+ $elements = array_filter(
+ $tab->getElements(),
+ fn (FieldLayoutElement $element) => ! $element instanceof EntryTitleField,
+ );
$tab->setElements($elements);
}
}
- return new CpScreenResponse()
- ->editUrl($entryTypeData->getCpEditUrl())
- ->title(trim($entryTypeData->name) ?: t('Edit Entry Type'))
- ->addCrumb(t('Settings'), 'settings')
- ->addCrumb(t('Entry Types'), 'settings/entry-types')
- ->contentTemplate('settings/entry-types/_edit.twig', [
- 'entryTypeId' => $entryTypeData->id,
- 'entryType' => $entryTypeData,
- 'typeName' => Entry::displayName(),
- 'lowerTypeName' => Entry::lowerDisplayName(),
- 'readOnly' => $this->readOnly,
- ])
- ->unless(
- $this->readOnly,
- callback: function (CpScreenResponse $response) use ($entryTypeData) {
- $response
- ->action('entry-types/save')
- ->redirectUrl('settings/entry-types')
- ->addAltAction(t('Save and continue editing'), [
- 'redirect' => 'settings/entry-types/{id}',
- 'shortcut' => true,
- 'retainScroll' => true,
- ])
- ->addAltAction(t('Save as a new entry type'), [
- 'params' => ['saveAsNew' => true],
- 'redirect' => 'settings/entry-types/{id}',
- ])
- ->addAltAction(t('Delete'), [
- 'action' => 'entry-types/delete',
- 'destructive' => true,
- ])
- ->metaSidebarHtml(app(ContentHtml::class)->metadataHtml([
- t('ID') => $entryTypeData->id,
- t('Used by') => function () use ($entryTypeData) {
- $usages = $entryTypeData->findUsages();
- if (empty($usages)) {
- return Html::tag('i', t('No usages'));
- }
-
- $labels = [];
- $items = array_map(function (Section|ElementContainerFieldInterface $usage) use (
- &$labels
- ) {
- $icon = $usage instanceof FieldInterface && ! $usage instanceof Iconic
- ? $usage::icon()
- : $usage->getIcon();
- $label = $labels[] = $usage->getUiLabel();
- $labelHtml = Html::beginTag('span', [
- 'class' => ['flex', 'flex-nowrap', 'gap-s'],
- ]).
- Html::tag('div', Icons::svg($icon), [
- 'class' => ['cp-icon', 'small'],
- ]).
- Html::tag('span', Html::encode($label)).
- Html::endTag('span');
-
- return Html::a($labelHtml, $usage->getCpEditUrl());
- }, $entryTypeData->findUsages());
-
- // sort by label
- array_multisort($labels, SORT_ASC, $items);
-
- $items = array_map(fn ($item) => Html::li($item)->encode(false), $items);
-
- return Html::ul()->items(...$items)->render();
- },
- ]));
- },
- default: function (CpScreenResponse $response) {
- $response->noticeHtml(app(ContentHtml::class)->readOnlyNoticeHtml());
- },
- );
+ // The field layout designer UI is deferred; round-trip the current config so saves preserve the layout.
+ $fieldLayoutConfig = [
+ 'uid' => $fieldLayout->uid,
+ ...(array) $fieldLayout->getConfig(),
+ ];
+ if ($fieldLayout->id) {
+ $fieldLayoutConfig['id'] = $fieldLayout->id;
+ }
+ $fieldLayoutConfig['type'] = Entry::class;
+
+ $translationMethodOptions = [
+ ['value' => TranslationMethod::None->value, 'label' => t('Not translatable')],
+ ['value' => TranslationMethod::Site->value, 'label' => t('Translate for each site')],
+ ['value' => TranslationMethod::SiteGroup->value, 'label' => t('Translate for each site group')],
+ ['value' => TranslationMethod::Language->value, 'label' => t('Translate for each language')],
+ ['value' => TranslationMethod::Custom->value, 'label' => t('Custom…')],
+ ];
+
+ return [
+ 'brandNew' => $brandNew,
+ 'entryType' => [
+ 'id' => $entryType->id,
+ 'name' => $entryType->name,
+ 'handle' => $entryType->handle,
+ 'description' => $entryType->description,
+ 'uiLabelFormat' => $entryType->uiLabelFormat,
+ 'titleTranslationMethod' => $entryType->titleTranslationMethod->value,
+ 'titleTranslationKeyFormat' => $entryType->titleTranslationKeyFormat,
+ 'titleFormat' => $entryType->titleFormat,
+ 'allowLineBreaksInTitles' => (bool) $entryType->allowLineBreaksInTitles,
+ 'showSlugField' => (bool) $entryType->showSlugField,
+ 'slugTranslationMethod' => $entryType->slugTranslationMethod->value,
+ 'slugTranslationKeyFormat' => $entryType->slugTranslationKeyFormat,
+ 'showStatusField' => (bool) $entryType->showStatusField,
+ ],
+ 'fieldLayoutConfig' => $fieldLayoutConfig,
+ 'translationMethodOptions' => $translationMethodOptions,
+ 'typeName' => Entry::displayName(),
+ 'lowerTypeName' => Entry::lowerDisplayName(),
+ 'isMultiSite' => Sites::isMultiSite(),
+ 'readOnly' => $this->readOnly,
+ ];
}
#[Deprecated(message: 'in 6.0. Use `settings/entry-types` instead.')]
From 91ebc68ae77177646f6101dd60193cdc7f7887ee Mon Sep 17 00:00:00 2001
From: Brian Hanson
Date: Thu, 18 Jun 2026 15:34:20 -0500
Subject: [PATCH 2/3] First pass at the new entry type page
---
.../craftcms-cp/src/components/input/input.ts | 2 +
.../craftcms-cp/src/styles/form.styles.ts | 5 +
.../craftcms-cp/src/styles/shared/tokens.css | 1 +
packages/craftcms-cp/tailwind.css | 16 +
packages/craftcms-legacy/cp/src/css/_fld.scss | 469 ----------------
.../craftcms-legacy/cp/src/css/craft.scss | 1 -
.../cp/src/js/FieldLayoutDesigner.js | 51 +-
.../craftcms-legacy/garnish/src/Garnish.js | 3 +
resources/css/cp.css | 1 +
resources/css/fld.css | 509 ++++++++++++++++++
.../composables/useFieldLayoutDesigner.ts | 52 ++
resources/js/common/types/globals.d.ts | 5 +
.../js/pages/settings/EntryTypesEdit.vue | 40 +-
resources/views/forms/fld/designer.blade.php | 115 ++++
resources/views/forms/fld/tab.blade.php | 33 ++
.../FieldLayoutDesigner.php | 145 ++---
src/FieldLayout/LayoutElements/BaseField.php | 9 +-
.../LayoutElements/HorizontalRule.php | 2 +-
src/FieldLayout/LayoutElements/LineBreak.php | 2 +-
.../Settings/EntryTypesController.php | 21 +-
20 files changed, 857 insertions(+), 625 deletions(-)
delete mode 100644 packages/craftcms-legacy/cp/src/css/_fld.scss
create mode 100644 resources/css/fld.css
create mode 100644 resources/js/common/composables/useFieldLayoutDesigner.ts
create mode 100644 resources/views/forms/fld/designer.blade.php
create mode 100644 resources/views/forms/fld/tab.blade.php
diff --git a/packages/craftcms-cp/src/components/input/input.ts b/packages/craftcms-cp/src/components/input/input.ts
index 7bafe449ff5..925e4fc50ac 100644
--- a/packages/craftcms-cp/src/components/input/input.ts
+++ b/packages/craftcms-cp/src/components/input/input.ts
@@ -14,6 +14,8 @@ export default class CraftInput extends LionInput {
@property({reflect: true, type: Boolean}) small = false;
@property({reflect: true, type: Boolean}) center = false;
+ @property({reflect: true, type: Boolean}) monospace = false;
+
override connectedCallback() {
super.connectedCallback();
diff --git a/packages/craftcms-cp/src/styles/form.styles.ts b/packages/craftcms-cp/src/styles/form.styles.ts
index eeba5d58567..a53d927560e 100644
--- a/packages/craftcms-cp/src/styles/form.styles.ts
+++ b/packages/craftcms-cp/src/styles/form.styles.ts
@@ -62,6 +62,11 @@ export const baseFieldStyles = css`
export const inputStyles = css`
${baseFieldStyles}
+ :host([monospace]) .input-group__container {
+ font-family: var(--c-font-mono);
+ font-size: 0.9em;
+ }
+
::slotted([slot='input']) {
font: inherit;
padding-block: 0;
diff --git a/packages/craftcms-cp/src/styles/shared/tokens.css b/packages/craftcms-cp/src/styles/shared/tokens.css
index 8338d1a75dd..f8db68dbb85 100644
--- a/packages/craftcms-cp/src/styles/shared/tokens.css
+++ b/packages/craftcms-cp/src/styles/shared/tokens.css
@@ -54,6 +54,7 @@
--c-spacing-2xl: calc(var(--c-spacing) * 16);
--c-size-touch-target: calc(34rem / 16);
+ --c-size-touch-target-sm: calc(24rem / 16);
--c-size-icon-xs: calc(10rem / 16);
--c-size-icon-sm: calc(12rem / 16);
diff --git a/packages/craftcms-cp/tailwind.css b/packages/craftcms-cp/tailwind.css
index fd95de39987..66436a6c699 100644
--- a/packages/craftcms-cp/tailwind.css
+++ b/packages/craftcms-cp/tailwind.css
@@ -110,4 +110,20 @@
--color-border-default: var(--c-color-neutral-border-normal);
--color-border-strong: var(--c-color-neutral-border-loud);
--color-border-form: var(--c-form-control-border-color);
+
+ /* ——— Fill utilities ——— */
+ --color-fill-quiet: var(
+ --c-color-fill-quiet,
+ var(--c-color-neutral-fill-quiet)
+ );
+ --color-fill-normal: var(
+ --c-color-fill-normal,
+ var(--c-color-neutral-fill-normal)
+ );
+ --color-fill-loud: var(--c-color-fill-loud, var(--c-color-neutral-fill-loud));
+
+ /* ——— Text utilities ——— */
+ --color-on-quiet: var(--c-color-on-quiet, var(--c-color-neutral-on-quiet));
+ --color-on-normal: var(--c-color-on-normal, var(--c-color-neutral-on-normal));
+ --color-on-loud: var(--c-color-on-loud, var(--c-color-neutral-on-loud));
}
diff --git a/packages/craftcms-legacy/cp/src/css/_fld.scss b/packages/craftcms-legacy/cp/src/css/_fld.scss
deleted file mode 100644
index 28b5b699e06..00000000000
--- a/packages/craftcms-legacy/cp/src/css/_fld.scss
+++ /dev/null
@@ -1,469 +0,0 @@
-@charset "UTF-8";
-@use 'sass:color';
-@use '@craftcms/sass/mixins';
-
-$base: 24px;
-$tabPadding: 14px;
-$tabWidth: $base * 12;
-$gridColor: var(--gray-100);
-
-@mixin workspace-bg {
- background-color: var(--gray-050);
- background-image:
- linear-gradient(to right, $gridColor 1px, transparent 0),
- linear-gradient(to bottom, $gridColor 1px, transparent 1px);
- background-size: $base $base;
-}
-
-.layoutdesigner {
- container-type: inline-size;
-}
-
-.fld-container {
- display: flex;
- align-items: stretch;
- position: relative;
- @include mixins.input-styles;
- overflow: hidden;
- box-shadow: none;
- min-height: 500px;
-
- .errors > .layoutdesigner > & {
- border: 1px solid var(--fg-error) !important;
- }
-
- .fld-workspace {
- flex: 1;
- max-width: 100%;
- border-start-start-radius: calc(var(--radius-sm) - 1px);
- border-start-end-radius: 0;
- border-end-end-radius: 0;
- border-end-start-radius: calc(var(--radius-sm) - 1px);
- padding-inline: $base 0;
- padding-block: $base;
- @include workspace-bg;
- background-position: -1px -1px;
- box-shadow: inset 0 1px 3px -1px
- color.adjust(mixins.$grey200, $lightness: -10%);
-
- .fld-tabs {
- display: flex;
- align-items: flex-start;
- flex-wrap: wrap;
- }
- }
-
- .fld-library {
- display: none;
- }
-}
-
-.fld-library-hud {
- width: $tabWidth - 1;
- max-width: 100%;
-
- .main {
- height: 100%;
- padding: $tabPadding;
- }
-}
-
-.fld-new-tab-btn:active {
- background-color: var(--gray-050);
-}
-
-.fld-library {
- display: flex;
- flex-direction: column;
- height: 100%;
-
- .btngroup {
- margin-block-end: $tabPadding;
- }
-
- .fld-field-library,
- .fld-ui-library {
- margin-block: -3px;
- margin-inline: #{-$tabPadding};
- padding-block: 3px;
- padding-inline: #{$tabPadding};
- flex: 1;
- min-height: 0;
- max-height: 550px;
- overflow: auto;
- }
-
- .fld-field-library {
- .fld-field-group {
- margin-block-start: $tabPadding;
-
- & > *:not(:first-child) {
- margin-block-start: var(--s);
- }
- }
-
- .fld-field-indicators {
- display: none;
- }
- }
-
- .fld-ui-library > *:not(:first-child) {
- margin-block-start: var(--s);
- }
-
- .filtered {
- display: none !important;
- }
-}
-
-.layoutdesigner .fld-library,
-.fld-tab .tabs .tab,
-.fld-tab .fld-tabcontent {
- background-color: var(--white);
- box-shadow:
- 0 0 0 1px color.adjust(mixins.$grey900, $alpha: -0.9),
- 0 2px 5px -2px color.adjust(mixins.$grey900, $alpha: -0.8);
-}
-
-.fld-new-tab-btn {
- background: var(--white) !important;
- box-shadow:
- 0 0 0 1px #{color.adjust(mixins.$grey900, $alpha: -0.9)},
- 0 2px 5px -2px #{color.adjust(mixins.$grey900, $alpha: -0.8)};
-}
-
-.fld-tab .settings::before,
-.fld-element .settings::before {
- margin-block-start: -2px;
- font-size: 16px;
- opacity: 0.5;
-}
-
-.fld-tab .settings:hover::before,
-.fld-tab .settings.active::before,
-.fld-element .settings:hover::before,
-.fld-element .settings.active::before {
- opacity: 1;
-}
-
-.fld-tab {
- width: $tabWidth + $base;
- max-width: 100%;
- padding-inline: 0 $base + 1;
- padding-block: 0 $base;
- box-sizing: border-box;
-
- .tabs {
- margin-block: -10px 0;
- margin-inline: -12px;
- padding-block: 10px 0;
- padding-inline: 12px;
- overflow: hidden;
- display: flex;
-
- .tab {
- display: flex;
- align-items: center;
- gap: var(--xs);
- max-width: calc(100% - 10px);
- box-sizing: border-box;
- padding-block: 8px;
- padding-inline: $tabPadding;
- border-radius: var(--radius-md) var(--radius-md) 0 0;
-
- body:not(.dragging) &.draggable {
- cursor: move;
- cursor: grab;
- }
- }
- }
-
- .fld-tab__name {
- margin-block: 0;
- font-weight: var(--font-weight-regular);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .fld-tabcontent {
- padding: $tabPadding;
- border-start-start-radius: 0;
- border-start-end-radius: var(--radius-md);
- border-end-end-radius: var(--radius-md);
- border-end-start-radius: var(--radius-md);
-
- & > .fld-element,
- & > .fld-add-btn {
- &:not(:first-child) {
- margin-block-start: var(--s);
- }
- }
- }
-
- &.fld-insertion {
- .tabs .tab,
- .fld-tabcontent {
- margin: -2px;
- border: 2px dashed var(--border-hairline);
- box-shadow: none;
- @include workspace-bg;
- }
-
- .tabs .tab {
- background-position: -1px -1px;
- }
-
- .fld-tabcontent {
- background-position: -1px -13px;
- }
- }
-}
-
-.fld-tab-caboose {
- min-height: 24px;
-}
-
-.fld-element {
- position: relative;
- display: flex;
- align-items: center;
- padding: var(--s);
- gap: var(--s);
- box-shadow: inset 0 0 0 1px var(--border-hairline);
- border-radius: var(--radius-md);
- background-color: var(--white);
-
- body:not(.dragging) & {
- cursor: move;
- cursor: grab;
- }
-
- &.fld-insertion {
- box-sizing: border-box;
- border: 2px dashed var(--border-hairline);
- border-radius: var(--radius-md);
- background: none;
- box-shadow: none;
- }
-
- &.draghelper {
- @include mixins.shadow;
- }
-
- &.fld-field {
- color: var(--fg-subtle);
- background-color: var(--gray-100);
-
- &:not(.draghelper, :focus) {
- box-shadow: none;
- }
-
- .field-name {
- display: flex;
- flex-direction: column;
- gap: var(--xs);
- }
- }
-
- .fld-element-icon {
- text-align: center;
-
- &,
- svg {
- width: 16px;
- height: 16px;
- }
-
- svg {
- @include mixins.svg-mask(var(--ui-control-color));
- }
- }
-
- .field-name {
- flex: 1;
- overflow: hidden;
-
- .fld-element-label,
- .fld-attribute {
- flex: 1;
- display: flex;
- align-items: center;
- gap: var(--xs);
- }
-
- .fld-element-label h4,
- .fld-attribute .smalltext {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .fld-element-label h4 {
- font-weight: normal;
- color: var(--text-color);
- margin: 0;
- }
- }
-}
-
-.fld-hr,
-.fld-br {
- position: relative;
- flex: 1;
- display: flex;
- justify-content: center;
-
- &::before {
- position: absolute;
- display: block;
- inset-block-start: calc(50% - 2px);
- inset-inline-start: 0;
- width: 100%;
- height: 4px;
- content: '';
- font-size: 0;
- border-radius: 2px;
- }
-
- .smalltext {
- position: relative;
- display: flex;
- justify-content: center;
- align-items: center;
- background-color: var(--gray-100);
- border-radius: var(--radius-lg);
- padding-block: 0;
- padding-inline: var(--s);
- height: var(--touch-target-size);
- }
-}
-
-.fld-hr::before {
- background-color: var(--gray-100);
-}
-
-.fld-br::before {
- background-image: repeating-linear-gradient(
- to right,
- var(--gray-100),
- var(--gray-100) calc(100% / 19),
- transparent calc(100% / 19),
- transparent calc(100% / 9.5),
- var(--gray-100) calc(100% / 9.5)
- );
-}
-
-.fld-element-settings-body {
- flex: 1;
- margin-block: -24px 0;
- margin-inline: var(--neg-padding);
- padding-block: 24px;
- padding-inline: var(--padding);
- overflow: hidden auto;
- position: relative;
-
- hr {
- margin-inline: -24px;
- }
-}
-
-.fld-element-settings-footer {
- position: relative;
- display: flex;
- flex-direction: row;
- margin-block: 0 -24px;
- margin-inline: var(--neg-padding);
- padding-block: 5px;
- padding-inline: var(--padding);
- @include mixins.pane;
- background-color: var(--gray-050);
- z-index: 3;
-
- & > .ee-site-select {
- flex: 1;
- }
-
- & > .btn {
- margin-inline-start: 5px;
- }
-
- & > .spinner {
- margin-inline: 0 var(--neg-padding);
- margin-block: 0;
- }
-}
-
-// thumbnail management
-.thumb-management {
- margin-block: var(--xl);
-
- .field:first-child {
- margin-inline-end: var(--xl);
- }
-}
-
-// card view designer
-.card-view-designer {
- container: cvd / inline-size;
-}
-
-.cvd-container {
- display: grid;
- position: relative;
- overflow: hidden;
- box-shadow: none;
- gap: var(--xl);
-}
-
-@container cvd (width > 37.5rem) {
- .cvd-container {
- grid-template-columns: 1fr 2fr;
- }
-}
-
-.cvd-library {
- .draggable {
- display: flex;
- }
-}
-
-.cvd-preview-container {
- padding: var(--xl);
- border: 1px solid #{color.adjust(mixins.$inputColor, $alpha: -0.75)} !important;
- border-radius: var(--radius-sm);
- display: grid;
- height: 100%;
- align-items: center;
-}
-
-.cvd-preview {
- &:not(.loading) .spinner {
- display: none;
- }
-}
-
-.cvd-thumbnail {
- --icon-size: 2rem;
- --icon-color: var(--gray-300);
- width: 100%;
- aspect-ratio: 4/3;
- display: flex;
- justify-content: center;
- align-items: center;
- background-color: var(--gray-150);
- border-radius: var(--radius-md);
-}
-
-.card-placeholder {
- display: inline-block;
- border: 1px dashed mixins.$grey300;
- border-radius: var(--radius-sm);
- padding-block: 0.1em;
- padding-inline: 0.5em;
-}
-
-.field.cvd-field {
- margin-block-start: 0.2em !important;
- margin-inline-start: 0.5em;
-}
diff --git a/packages/craftcms-legacy/cp/src/css/craft.scss b/packages/craftcms-legacy/cp/src/css/craft.scss
index ff419da226b..30fb155eeac 100644
--- a/packages/craftcms-legacy/cp/src/css/craft.scss
+++ b/packages/craftcms-legacy/cp/src/css/craft.scss
@@ -17,7 +17,6 @@
@import 'craft-tooltip';
@import 'preview';
@import 'entry-type-select';
- @import 'fld';
@import 'grouped-entry-type-select';
@import 'image_editor';
@import 'shame';
diff --git a/packages/craftcms-legacy/cp/src/js/FieldLayoutDesigner.js b/packages/craftcms-legacy/cp/src/js/FieldLayoutDesigner.js
index cc9a7f4f81e..7f13ae4aa25 100644
--- a/packages/craftcms-legacy/cp/src/js/FieldLayoutDesigner.js
+++ b/packages/craftcms-legacy/cp/src/js/FieldLayoutDesigner.js
@@ -46,7 +46,7 @@ Craft.FieldLayoutDesigner = Garnish.Base.extend(
this.$innerContainer = this.$container.children('.fld-container');
const $workspace = this.$innerContainer.children('.fld-workspace');
this.$tabContainer = $workspace.children('.fld-tabs');
- this.$newTabBtn = $workspace.children('.fld-new-tab-btn');
+ this.$newTabBtn = $workspace.find('[command="--add-tab"]');
this.$libraryContainer = this.$innerContainer.children('.fld-library');
this.$fieldLibrary = this.$selectedLibrary =
@@ -245,9 +245,16 @@ Craft.FieldLayoutDesigner = Garnish.Base.extend(
-
+
`);
@@ -500,7 +507,7 @@ Craft.FieldLayoutDesigner.Tab = Garnish.Base.extend({
this.designer.tabGrid.refreshCols(true);
});
- this.$addBtn = $tabContent.children('.fld-add-btn');
+ this.$addBtn = $tabContent.find('[command="--add-field"]');
const hud = new Garnish.HUD(this.$addBtn, {
hudClass: 'hud fld-library-hud cp-legacy',
@@ -523,7 +530,9 @@ Craft.FieldLayoutDesigner.Tab = Garnish.Base.extend({
hud.show();
});
- const $elements = $tabContent.children().not(this.$addBtn);
+ // Match the drag system's item selector (ElementDrag.findItems) so every
+ // draggable element gets initialized, regardless of how it's nested.
+ const $elements = $tabContent.find('.fld-element');
for (let i = 0; i < $elements.length; i++) {
this.initElement($($elements[i]));
@@ -533,16 +542,23 @@ Craft.FieldLayoutDesigner.Tab = Garnish.Base.extend({
createMenu: function () {
const $tab = this.$container.find('.tabs .tab');
const menuId = `actionmenu${Math.floor(Math.random() * 1000000)}`;
- const $btn = $('', {
+ const $btn = $('', {
type: 'button',
- class: 'btn action-btn',
+ icon: true,
+ size: 'small',
+ appearance: 'plain',
'data-disclosure-trigger': 'true',
'aria-controls': menuId,
'aria-haspopup': 'true',
- 'aria-label': Craft.t('app', 'Actions'),
- title: Craft.t('app', 'Actions'),
disabled: this.designer.settings.readOnly,
}).appendTo($tab);
+
+ $('', {
+ slot: 'prefix',
+ name: 'ellipsis',
+ label: Craft.t('app', 'Actions')
+ }).appendTo($btn);
+
const $menu = $('', {
id: menuId,
class: 'menu menu--disclosure',
@@ -930,16 +946,23 @@ Craft.FieldLayoutDesigner.Element = Garnish.Base.extend({
// create the action menu
const menuId = `actionmenu${Math.floor(Math.random() * 1000000)}`;
- this.$actionBtn = $('', {
+ this.$actionBtn = $('', {
type: 'button',
- class: 'btn action-btn',
+ size: 'small',
'data-disclosure-trigger': 'true',
'aria-controls': menuId,
'aria-haspopup': 'true',
- 'aria-label': Craft.t('app', 'Actions'),
- title: Craft.t('app', 'Actions'),
+ icon: true,
+ appearance: 'plain',
disabled: this.tab.designer.settings.readOnly,
}).appendTo(this.$container);
+
+ $('', {
+ label: Craft.t('app', 'Actions'),
+ slot: 'prefix',
+ name: 'ellipsis',
+ }).appendTo(this.$actionBtn);
+
$('', {
id: menuId,
class: 'menu menu--disclosure',
@@ -1785,7 +1808,7 @@ Craft.FieldLayoutDesigner.ElementDrag =
for (let i = 0; i < $fieldContainers.length; i++) {
$caboose = $caboose.add(
$('').insertBefore(
- $fieldContainers.eq(i).children('.fld-add-btn')
+ $fieldContainers.eq(i).find('[command="--add-field"]')
)
);
}
diff --git a/packages/craftcms-legacy/garnish/src/Garnish.js b/packages/craftcms-legacy/garnish/src/Garnish.js
index 3c2ab1ea1f8..999c902b026 100644
--- a/packages/craftcms-legacy/garnish/src/Garnish.js
+++ b/packages/craftcms-legacy/garnish/src/Garnish.js
@@ -1049,6 +1049,7 @@ function initialize() {
// Prevent buttons from getting focus on click
if (
e.currentTarget.nodeName === 'BUTTON' ||
+ e.currentTarget.nodeName === 'CRAFT-BUTTON' ||
e.currentTarget.role === 'button'
) {
e.preventDefault();
@@ -1075,6 +1076,7 @@ function initialize() {
if (
e.currentTarget.nodeName === 'BUTTON' ||
+ e.currentTarget.nodeName === 'CRAFT-BUTTON' ||
e.currentTarget.role === 'button'
) {
e.preventDefault();
@@ -1099,6 +1101,7 @@ function initialize() {
if (
e.currentTarget.nodeName === 'BUTTON' ||
+ e.currentTarget.nodeName === 'CRAFT-BUTTON' ||
e.currentTarget.role === 'button'
) {
e.preventDefault();
diff --git a/resources/css/cp.css b/resources/css/cp.css
index ea81334bdd5..526f141e49e 100644
--- a/resources/css/cp.css
+++ b/resources/css/cp.css
@@ -7,6 +7,7 @@ CP Styles
@import '@craftcms/cp/styles/cp.css' layer(cp);
@import '@craftcms/cp/tailwind.css' layer(theme);
+@import './fld.css';
@import './global-sidebar.css';
:root {
diff --git a/resources/css/fld.css b/resources/css/fld.css
new file mode 100644
index 00000000000..ddec34d5d13
--- /dev/null
+++ b/resources/css/fld.css
@@ -0,0 +1,509 @@
+/* -------------------------------------------------------------------------
+ Field layout designer styles.
+
+ Ported from packages/craftcms-legacy/cp/src/css/_fld.scss — SCSS mixins +
+ color math compiled to literals, legacy CSS variables mapped to the new
+ --c-* tokens (per tokens.css). Anything left as an old var has a TODO
+ comment — no confident token equivalent yet.
+
+ Organized to mirror the designer markup (see
+ resources/views/_includes/forms/fld/designer.blade.php):
+ root → workspace → tabs → elements → dividers → library → settings → card view.
+ ------------------------------------------------------------------------- */
+
+:root {
+ --workspace-bg-size: calc(24rem / 16) calc(24rem / 16);
+ --workspace-bg-image:
+ linear-gradient(
+ to right,
+ color-mix(var(--c-color-neutral-border-normal), transparent 85%) 1px,
+ transparent 0
+ ),
+ linear-gradient(
+ to bottom,
+ color-mix(var(--c-color-neutral-border-normal), transparent 85%) 1px,
+ transparent 1px
+ );
+}
+
+/* Designer root + container */
+.layoutdesigner {
+ container-type: inline-size;
+}
+
+.fld-container {
+ display: flex;
+ align-items: stretch;
+ position: relative;
+ border-radius: var(--c-radius-md);
+ border: 1px solid var(--c-color-border-quiet);
+ background: var(--c-color-fill-quiet);
+ background-clip: padding-box;
+ overflow: hidden;
+ box-shadow: none;
+ min-height: 500px;
+}
+
+.errors > .layoutdesigner > .fld-container {
+ border: 1px solid var(--c-color-danger-border-normal);
+}
+
+/* Workspace */
+.fld-workspace {
+ flex: 1;
+ max-width: 100%;
+ border-start-start-radius: calc(var(--c-radius-sm) - 1px);
+ border-start-end-radius: 0;
+ border-end-end-radius: 0;
+ border-end-start-radius: calc(var(--c-radius-sm) - 1px);
+ padding-inline: 24px 0;
+ padding-block: 24px;
+ background-color: var(--c-color-neutral-fill-quiet);
+ background-image: var(--workspace-bg-image);
+ background-size: var(--workspace-bg-size);
+ background-position: -1px -1px;
+ box-shadow: var(--c-shadow-sunken);
+}
+
+.fld-tabs {
+ display: flex;
+ align-items: flex-start;
+ flex-wrap: wrap;
+}
+
+/* Tabs */
+.fld-tab {
+ width: 312px;
+ max-width: 100%;
+ padding-inline: 0 25px;
+ padding-block: 0 24px;
+ box-sizing: border-box;
+}
+
+.fld-tab .tabs {
+ margin-block: -10px 0;
+ margin-inline: -12px;
+ padding-block: 10px 0;
+ padding-inline: 12px;
+ overflow: hidden;
+ display: flex;
+}
+
+.fld-tab .tab {
+ display: flex;
+ align-items: center;
+ gap: var(--c-spacing-sm);
+ max-width: calc(100% - 10px);
+ box-sizing: border-box;
+ padding-block: 8px;
+ padding-inline: 14px;
+ border-radius: var(--c-radius-md) var(--c-radius-md) 0 0;
+}
+
+body:not(.dragging) .fld-tab .tab.draggable {
+ cursor: move;
+ cursor: grab;
+}
+
+.fld-tab__name {
+ margin-block: 0;
+ font-size: var(--c-text-base);
+ font-weight: normal;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Tab + tab content surface */
+.fld-tab .tab,
+.fld-tabcontent {
+ background-color: var(--c-surface-overlay);
+ box-shadow:
+ 0 0 0 1px hsl(210deg 24% 16% / 10%),
+ 0 2px 5px -2px hsl(210deg 24% 16% / 20%);
+}
+
+.fld-tabcontent {
+ display: grid;
+ gap: var(--c-spacing-sm);
+ padding: var(--c-spacing-lg);
+ border-start-start-radius: 0;
+ border-start-end-radius: var(--c-radius-md);
+ border-end-end-radius: var(--c-radius-md);
+ border-end-start-radius: var(--c-radius-md);
+}
+
+.fld-tab.fld-insertion .tab,
+.fld-tab.fld-insertion .fld-tabcontent {
+ border: 2px dashed var(--c-color-neutral-border-quiet);
+ box-shadow: none;
+ background-color: var(--c-color-neutral-fill-quiet);
+ background-image: var(--workspace-bg-image);
+ background-size: var(--workspace-bg-size);
+}
+
+.fld-tab.fld-insertion .tab {
+ background-position: -1px -1px;
+}
+
+.fld-tab.fld-insertion .fld-tabcontent {
+ background-position: -1px -13px;
+}
+
+.fld-tab-caboose {
+ min-height: 24px;
+}
+
+.fld-new-tab-btn {
+ background: var(--c-surface-overlay);
+ box-shadow:
+ 0 0 0 1px hsl(210deg 24% 16% / 10%),
+ 0 2px 5px -2px hsl(210deg 24% 16% / 20%);
+}
+
+.fld-new-tab-btn:active {
+ background-color: var(--c-color-neutral-fill-quiet);
+}
+
+/* Layout elements (chips) */
+
+/* Settings (gear) action button — shown on tabs and elements */
+.fld-tab .settings::before,
+.fld-element .settings::before {
+ margin-block-start: -2px;
+ font-size: 16px;
+ opacity: 0.5;
+}
+
+.fld-tab .settings:hover::before,
+.fld-tab .settings.active::before,
+.fld-element .settings:hover::before,
+.fld-element .settings.active::before {
+ opacity: 1;
+}
+
+.fld-element {
+ position: relative;
+ display: flex;
+ align-items: center;
+ padding: var(--c-spacing-sm);
+ gap: var(--c-spacing-sm);
+ box-shadow: inset 0 0 0 1px var(--c-color-neutral-border-quiet);
+ border-radius: var(--c-radius-md);
+ background-color: var(--c-surface-overlay);
+}
+
+body:not(.dragging) .fld-element {
+ cursor: move;
+ cursor: grab;
+}
+
+.fld-element.fld-insertion {
+ box-sizing: border-box;
+ border: 2px dashed var(--c-color-neutral-border-quiet);
+ border-radius: var(--c-radius-md);
+ background: none;
+ box-shadow: none;
+}
+
+.fld-element.draghelper {
+ box-shadow: 0 1px 5px -1px hsl(210deg 24% 16% / 20%);
+}
+
+.fld-element.fld-field {
+ color: var(--c-color-on-quiet);
+ background-color: var(--c-color-fill-quiet);
+ border: 1px solid var(--c-color-border-quiet);
+}
+
+.fld-element.fld-field:not(.draghelper, :focus) {
+ box-shadow: none;
+}
+
+.fld-element.fld-field .field-name {
+ display: flex;
+ flex-direction: column;
+ gap: var(--c-spacing-xs);
+}
+
+.fld-element-icon {
+ text-align: center;
+ align-self: start;
+ position: relative;
+ inset-block-start: calc((1lh - 1em) / 2);
+ width: 1em;
+ height: 1em;
+}
+
+.fld-element-icon svg {
+ width: 90%;
+ height: 90%;
+}
+
+.fld-element-icon svg * {
+ fill: currentColor;
+ stroke-width: 0;
+}
+
+.fld-element .field-name {
+ flex: 1;
+ overflow: hidden;
+}
+
+.fld-element-label,
+.fld-attribute {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: var(--c-spacing-xs);
+}
+
+.fld-element-label h4,
+.fld-attribute .smalltext {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.fld-element-label h4 {
+ font-weight: normal;
+ color: var(--c-text-default);
+ margin: 0;
+}
+
+/* UI element dividers (horizontal rule / line break) */
+.fld-hr,
+.fld-br {
+ position: relative;
+ flex: 1;
+ display: flex;
+ justify-content: center;
+}
+
+.fld-hr::before,
+.fld-br::before {
+ position: absolute;
+ display: block;
+ inset-block-start: calc(50% - 2px);
+ inset-inline-start: 0;
+ width: 100%;
+ height: 4px;
+ content: '';
+ font-size: 0;
+ border-radius: 2px;
+}
+
+.fld-hr__label,
+.fld-br__label {
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ font-size: var(--c-text-sm);
+ color: var(--c-color-on-quiet);
+ background-color: var(--c-color-fill-quiet);
+ border-radius: var(--c-radius-lg);
+ padding-block: 0;
+ padding-inline: var(--c-spacing-md);
+ height: var(--c-size-touch-target-sm);
+}
+
+.fld-hr::before {
+ background-color: var(--c-color-border-quiet);
+}
+
+.fld-br::before {
+ background-image: repeating-linear-gradient(
+ to right,
+ var(--c-color-border-quiet),
+ var(--c-color-border-quiet) 5.2632%,
+ transparent 5.2632%,
+ transparent 10.5263%,
+ var(--c-color-border-quiet) 10.5263%
+ );
+}
+
+/* Library */
+.fld-library {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.fld-container .fld-library {
+ display: none;
+}
+
+.layoutdesigner .fld-library {
+ background-color: var(--c-surface-overlay);
+ box-shadow:
+ 0 0 0 1px hsl(210deg 24% 16% / 10%),
+ 0 2px 5px -2px hsl(210deg 24% 16% / 20%);
+}
+
+.fld-library .btngroup {
+ margin-block-end: 14px;
+}
+
+.fld-field-library,
+.fld-ui-library {
+ margin-block: -3px;
+ margin-inline: -14px;
+ padding-block: 3px;
+ padding-inline: 14px;
+ flex: 1;
+ min-height: 0;
+ max-height: 550px;
+ overflow: auto;
+}
+
+.fld-field-group {
+ margin-block-start: 14px;
+}
+
+.fld-field-group > *:not(:first-child) {
+ margin-block-start: var(--c-spacing-sm);
+}
+
+/*.fld-field-indicators {*/
+/* display: none;*/
+/*}*/
+
+.fld-ui-library > *:not(:first-child) {
+ margin-block-start: var(--c-spacing-sm);
+}
+
+.fld-library .filtered {
+ display: none;
+}
+
+.fld-library-hud {
+ width: 287px;
+ max-width: 100%;
+}
+
+.fld-library-hud .main {
+ height: 100%;
+ padding: 14px;
+}
+
+/* Element settings (slideout) */
+.fld-element-settings-body {
+ flex: 1;
+ margin-block: -24px 0;
+
+ /* TODO: no token yet for --neg-padding */
+ margin-inline: var(--neg-padding);
+ padding-block: 24px;
+
+ /* TODO: no token yet for --padding */
+ padding-inline: var(--padding);
+ overflow: hidden auto;
+ position: relative;
+}
+
+.fld-element-settings-body hr {
+ margin-inline: -24px;
+}
+
+.fld-element-settings-footer {
+ position: relative;
+ display: flex;
+ flex-direction: row;
+ margin-block: 0 -24px;
+
+ /* TODO: no token yet for --neg-padding */
+ margin-inline: var(--neg-padding);
+ padding-block: 5px;
+
+ /* TODO: no token yet for --padding */
+ padding-inline: var(--padding);
+ box-shadow: var(--c-pane-shadow);
+ background-color: var(--c-color-neutral-fill-quiet);
+ z-index: 3;
+}
+
+.fld-element-settings-footer > .ee-site-select {
+ flex: 1;
+}
+
+.fld-element-settings-footer > .btn {
+ margin-inline-start: 5px;
+}
+
+.fld-element-settings-footer > .spinner {
+ /* TODO: no token yet for --neg-padding */
+ margin-inline: 0 var(--neg-padding);
+ margin-block: 0;
+}
+
+/* Thumbnail management */
+.thumb-management {
+ margin-block: var(--c-spacing-xl);
+}
+
+.thumb-management .field:first-child {
+ margin-inline-end: var(--c-spacing-xl);
+}
+
+/* Card view designer */
+.card-view-designer {
+ container: cvd / inline-size;
+}
+
+.cvd-container {
+ display: grid;
+ position: relative;
+ overflow: hidden;
+ box-shadow: none;
+ gap: var(--c-spacing-xl);
+}
+@container cvd (width > 37.5rem) {
+ .cvd-container {
+ grid-template-columns: 1fr 2fr;
+ }
+}
+
+.cvd-library .draggable {
+ display: flex;
+}
+
+.cvd-preview-container {
+ padding: var(--c-spacing-xl);
+ border: 1px solid hsl(212deg 25% 50% / 25%);
+ border-radius: var(--c-radius-sm);
+ display: grid;
+ height: 100%;
+ align-items: center;
+}
+
+.cvd-preview:not(.loading) .spinner {
+ display: none;
+}
+
+.cvd-thumbnail {
+ --icon-size: 2rem;
+ --icon-color: currentcolor;
+ width: 100%;
+ aspect-ratio: 4/3;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ background-color: var(--c-surface-overlay);
+ border-radius: var(--c-radius-md);
+}
+
+.card-placeholder {
+ display: inline-block;
+ border: 1px dashed hsl(211deg 13% 65%);
+ border-radius: var(--c-radius-sm);
+ padding-block: 0.1em;
+ padding-inline: 0.5em;
+}
+
+.field.cvd-field {
+ margin-block-start: 0.2em;
+ margin-inline-start: 0.5em;
+}
diff --git a/resources/js/common/composables/useFieldLayoutDesigner.ts b/resources/js/common/composables/useFieldLayoutDesigner.ts
new file mode 100644
index 00000000000..792661b1523
--- /dev/null
+++ b/resources/js/common/composables/useFieldLayoutDesigner.ts
@@ -0,0 +1,52 @@
+import {onMounted, type Ref} from 'vue';
+
+export interface FieldLayoutDesignerData {
+ /** Server-rendered field layout designer markup (rendered with `autoBoot: false`). */
+ html: string;
+}
+
+/**
+ * Boots the legacy `Craft.FieldLayoutDesigner` inside an Inertia page.
+ *
+ * The designer keeps its own hidden `fieldLayout` input in sync, so `serialize()`
+ * reads the value back out at submit — the same thing a Garnish form would post.
+ */
+export function useFieldLayoutDesigner(
+ hostRef: Ref,
+ data: FieldLayoutDesignerData
+) {
+ onMounted(async () => {
+ const host = hostRef.value;
+ if (!host) {
+ return;
+ }
+
+ host.innerHTML = data.html;
+
+ const designerEl = host.querySelector('.layoutdesigner');
+ if (designerEl) {
+ const settings = JSON.parse(designerEl.dataset.settings ?? '{}');
+ new window.Craft.FieldLayoutDesigner(designerEl, settings);
+ }
+
+ window.Craft?.initUiElements?.(host);
+ });
+
+ /**
+ * Serializes the designer's inputs into a nested object (fieldLayout, …),
+ * exactly like a Garnish form's jQuery serialization.
+ */
+ function serialize(): string {
+ const host = hostRef.value;
+ if (!host) {
+ return '{}';
+ }
+
+ const configInput = host.querySelector(
+ '[name="fieldLayout"]'
+ );
+ return configInput?.value || '{}';
+ }
+
+ return {serialize};
+}
diff --git a/resources/js/common/types/globals.d.ts b/resources/js/common/types/globals.d.ts
index 3b53a44b5ba..0c55bdf6e86 100644
--- a/resources/js/common/types/globals.d.ts
+++ b/resources/js/common/types/globals.d.ts
@@ -74,6 +74,8 @@ interface ElementSelectorModalInstance {
on(event: string, callback: () => void): void;
}
+type FieldLayoutDesignerInstance = any;
+
interface ElementSelectorModalSettings {
closeOtherModals?: boolean;
criteria?: Record;
@@ -141,6 +143,9 @@ interface CraftStatic {
CpScreenSlideout: {
new (url: string, settings?: object): SlideoutInstance;
};
+ FieldLayoutDesigner: {
+ new (container: any, settings?: object): FieldLayoutDesignerInstance;
+ };
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
diff --git a/resources/js/pages/settings/EntryTypesEdit.vue b/resources/js/pages/settings/EntryTypesEdit.vue
index cae9e38d987..66ce08897ca 100644
--- a/resources/js/pages/settings/EntryTypesEdit.vue
+++ b/resources/js/pages/settings/EntryTypesEdit.vue
@@ -8,8 +8,13 @@
import CraftTextarea from '@craftcms/cp/vue/CraftTextarea.vue';
import CraftSwitch from '@craftcms/cp/vue/CraftSwitch.vue';
import CraftSelect from '@craftcms/cp/vue/CraftSelect.vue';
+ import {ref} from 'vue';
import {useInputGenerator} from '@/common/composables/useInputGenerator';
import {useSettingsSave} from '@/modules/settings/composables/useSettingsSave';
+ import {
+ useFieldLayoutDesigner,
+ type FieldLayoutDesignerData,
+ } from '@/common/composables/useFieldLayoutDesigner';
import useCraftData from '@/common/composables/useCraftData';
import Pane from '@/common/components/Pane.vue';
import {store} from '@actions/Settings/EntryTypesController';
@@ -36,7 +41,7 @@
crumbs: Array;
entryType: EntryTypeData;
brandNew: boolean;
- fieldLayoutConfig: Record;
+ fieldLayoutDesigner: FieldLayoutDesignerData;
translationMethodOptions: Array;
typeName: string;
lowerTypeName: string;
@@ -61,11 +66,14 @@
slugTranslationMethod: props.entryType.slugTranslationMethod,
slugTranslationKeyFormat: props.entryType.slugTranslationKeyFormat ?? '',
showStatusField: props.entryType.showStatusField,
- // The field layout designer UI is deferred — round-trip the existing layout
- // config unchanged so saving preserves it. (Submitted as a JSON string.)
- fieldLayout: JSON.stringify(props.fieldLayoutConfig),
+ // `fieldLayout` (+ generatedFields / card view) are merged in at submit from
+ // the designer's own inputs — see the transform passed to useSettingsSave.
});
+ // Boot the (legacy) field layout designer and read its value back at submit.
+ const fldHost = ref();
+ const fld = useFieldLayoutDesigner(fldHost, props.fieldLayoutDesigner);
+
// Auto-generate the handle from the name for new entry types.
const handleGenerator = useInputGenerator(
() => form.name,
@@ -87,7 +95,9 @@
() => form.showSlugField && form.slugTranslationMethod === 'custom'
);
- const {save} = useSettingsSave(form, store);
+ const {save} = useSettingsSave(form, store, {
+ transform: (data) => ({...data, fieldLayout: fld.serialize()}),
+ });
@@ -193,7 +203,7 @@
v-model="form.uiLabelFormat"
:disabled="readOnly"
:error="errors?.uiLabelFormat"
- class="font-mono"
+ monospaced
/>
@@ -247,7 +257,7 @@
v-model="form.titleFormat"
:disabled="readOnly"
:error="errors?.titleFormat"
- class="font-mono"
+ monospace
/>
@@ -327,19 +337,11 @@
-
- {{
- t(
- 'The field layout designer will be available here soon. Existing field layouts are preserved when saving.'
- )
- }}
-
+
diff --git a/resources/views/forms/fld/designer.blade.php b/resources/views/forms/fld/designer.blade.php
new file mode 100644
index 00000000000..1080d019fbf
--- /dev/null
+++ b/resources/views/forms/fld/designer.blade.php
@@ -0,0 +1,115 @@
+@php
+ /**
+ * Field layout designer markup.
+ *
+ * Split out of FieldLayoutDesigner::html()'s Html::* helper chain. The PHP method
+ * still does all the setup (tabs, settings, available fields, config) and passes
+ * it here. Dynamic sections defer to the designer's render methods so the markup
+ * stays exactly what the legacy Craft.FieldLayoutDesigner JS expects.
+ *
+ * Declared in a PHP docblock (rather than a Blade comment) so PhpStorm types
+ * these variables and counts the $designer->… render calls as real usages.
+ *
+ * @var \CraftCms\Cms\Cp\FieldLayoutDesigner\FieldLayoutDesigner $designer
+ * @var string $id
+ * @var bool $autoBoot
+ * @var array $settings
+ * @var array $fieldLayoutConfig
+ * @var \CraftCms\Cms\FieldLayout\FieldLayout $fieldLayout
+ * @var \CraftCms\Cms\FieldLayout\FieldLayoutTab[] $tabs
+ * @var bool $customizableTabs
+ * @var bool $customizableUi
+ * @var bool $disabled
+ * @var array $availableNativeFields
+ * @var array $availableCustomFields
+ * @var \CraftCms\Cms\FieldLayout\FieldLayoutElement[] $availableUiElements
+ */
+@endphp
+
+
+
+
+
+
+ @foreach ($tabs as $tab)
+ {!! $designer->fldTabHtml($tab, $customizableTabs, $disabled) !!}
+ @endforeach
+
+
+ @if ($customizableTabs)
+
+
+ {{ \CraftCms\Cms\t('New Tab') }}
+ @endif
+
+
+
+ @if ($customizableUi)
+
+
+
+
+ @endif
+
+
+
+ {!! \CraftCms\Cms\Cp\FormFields::textHtml([
+ 'class' => 'fullwidth',
+ 'inputmode' => 'search',
+ 'placeholder' => \CraftCms\Cms\t('Search'),
+ 'disabled' => $disabled,
+ ]) !!}
+
+
+
+ {!! $designer->fldFieldSelectorsHtml(\CraftCms\Cms\t('Native Fields'), $availableNativeFields, $fieldLayout) !!}
+
+ @foreach ($availableCustomFields as $groupName => $groupFields)
+ {!! $designer->fldFieldSelectorsHtml($groupName, $groupFields, $fieldLayout) !!}
+ @endforeach
+
+
+ @if ($customizableUi)
+
+ @foreach ($availableUiElements as $element)
+ {!! $designer->layoutElementSelectorHtml($element, true, [
+ 'class' => array_filter([
+ ! $designer->showFldUiElementSelector($fieldLayout, $element) ? 'hidden' : null,
+ ]),
+ ]) !!}
+ @endforeach
+
+ @endif
+
+
+
diff --git a/resources/views/forms/fld/tab.blade.php b/resources/views/forms/fld/tab.blade.php
new file mode 100644
index 00000000000..a79d147f0d6
--- /dev/null
+++ b/resources/views/forms/fld/tab.blade.php
@@ -0,0 +1,33 @@
+@php
+ /**
+ * A single tab within the field layout designer.
+ *
+ * @var \CraftCms\Cms\Cp\FieldLayoutDesigner\FieldLayoutDesigner $designer
+ * @var \CraftCms\Cms\FieldLayout\FieldLayoutTab $tab
+ * @var bool $customizable
+ * @var bool $disabled
+ */
+@endphp
+
+
+
$customizable])>
+ {!! $tab->labelHtml() !!}
+
+
+
+ @foreach ($tab->getElements() as $element)
+ {!! $designer->layoutElementSelectorHtml($element) !!}
+ @endforeach
+
+
+ {{ \CraftCms\Cms\t('Add') }}
+
+
+
diff --git a/src/Cp/FieldLayoutDesigner/FieldLayoutDesigner.php b/src/Cp/FieldLayoutDesigner/FieldLayoutDesigner.php
index 6e49106e878..9943e14613c 100644
--- a/src/Cp/FieldLayoutDesigner/FieldLayoutDesigner.php
+++ b/src/Cp/FieldLayoutDesigner/FieldLayoutDesigner.php
@@ -87,20 +87,29 @@ public function html(FieldLayout $fieldLayout, array $config = []): string
$tab->setElements($layoutElements);
}
- $jsSettings = JsonHelper::encode([
+ $settings = [
'elementType' => $fieldLayout->type,
'customizableTabs' => $config['customizableTabs'],
'customizableUi' => $config['customizableUi'],
'withCardViewDesigner' => $config['withCardViewDesigner'] ?? false,
'alwaysShowThumbAlignmentBtns' => $fieldLayout->type::hasThumbs(),
'readOnly' => $config['disabled'],
- ]);
- $namespacedId = InputNamespace::namespaceId($config['id']);
+ ];
- $js = <<getAvailableCustomFields();
$availableNativeFields = $fieldLayout->getAvailableNativeFields();
@@ -139,81 +148,21 @@ public function html(FieldLayout $fieldLayout, array $config = []): string
$fieldLayoutConfig['type'] = $fieldLayout->type;
}
- return
- Html::beginTag('div', [
- 'id' => $config['id'],
- 'class' => 'layoutdesigner',
- ]).
- Html::hiddenInput('fieldLayout', JsonHelper::encode($fieldLayoutConfig), [
- 'data' => ['config-input' => true],
- ]).
- Html::beginTag('div', ['class' => 'fld-container']).
- Html::beginTag('div', ['class' => 'fld-workspace']).
- Html::beginTag('div', ['class' => 'fld-tabs']).
- implode('', array_map(
- fn (FieldLayoutTab $tab) => $this->fldTabHtml($tab, $config['customizableTabs'], $config['disabled']),
- $tabs,
- )).
- Html::endTag('div'). // .fld-tabs
- ($config['customizableTabs']
- ? Html::button(t('New Tab'), [
- 'type' => 'button',
- 'class' => ['fld-new-tab-btn', 'btn', 'add', 'icon'],
- 'disabled' => $config['disabled'],
- ])
- : '').
- Html::endTag('div'). // .fld-workspace
- Html::beginTag('div', ['class' => 'fld-library']).
- ($config['customizableUi']
- ? Html::beginTag('section', [
- 'class' => ['btngroup', 'btngroup--exclusive', 'small', 'fullwidth'],
- 'aria' => ['label' => t('Layout element types')],
- ]).
- Html::button(t('Fields'), [
- 'type' => 'button',
- 'class' => ['btn', 'small', 'active'],
- 'aria' => ['pressed' => 'true'],
- 'data' => ['library' => 'field'],
- 'disabled' => $config['disabled'],
- ]).
- Html::button(t('UI Elements'), [
- 'type' => 'button',
- 'class' => ['btn', 'small'],
- 'aria' => ['pressed' => 'false'],
- 'data' => ['library' => 'ui'],
- 'disabled' => $config['disabled'],
- ]).
- Html::endTag('section') // .btngroup
- : '').
- Html::beginTag('div', ['class' => 'fld-field-library']).
- Html::beginTag('div', ['class' => ['texticon', 'search', 'icon', 'clearable']]).
- FormFields::textHtml([
- 'class' => 'fullwidth',
- 'inputmode' => 'search',
- 'placeholder' => t('Search'),
- 'disabled' => $config['disabled'],
- ]).
- Html::tag('div', '', [
- 'class' => ['clear-btn', 'hidden'],
- 'title' => t('Clear'),
- 'aria' => ['label' => t('Clear')],
- ]).
- Html::endTag('div'). // .texticon
- $this->fldFieldSelectorsHtml(t('Native Fields'), $availableNativeFields, $fieldLayout).
- implode('', array_map(fn (string $groupName) => $this->fldFieldSelectorsHtml($groupName, $availableCustomFields[$groupName], $fieldLayout), array_keys($availableCustomFields))).
- Html::endTag('div'). // .fld-field-library
- ($config['customizableUi']
- ? Html::beginTag('div', ['class' => ['fld-ui-library', 'hidden']]).
- implode('', array_map(fn (FieldLayoutElement $element) => $this->layoutElementSelectorHtml($element, true, [
- 'class' => array_filter([
- ! $this->showFldUiElementSelector($fieldLayout, $element) ? 'hidden' : null,
- ]),
- ]), $availableUiElements)).
- Html::endTag('div') // .fld-ui-library
- : '').
- Html::endTag('div'). // .fld-library
- Html::endTag('div'). // .fld-container
- Html::endTag('div'); // .layoutdesigner
+ return view('c::forms.fld.designer', [
+ 'designer' => $this,
+ 'id' => $config['id'],
+ 'autoBoot' => $autoBoot,
+ 'settings' => $settings,
+ 'fieldLayoutConfig' => $fieldLayoutConfig,
+ 'fieldLayout' => $fieldLayout,
+ 'tabs' => $tabs,
+ 'customizableTabs' => $config['customizableTabs'],
+ 'customizableUi' => $config['customizableUi'],
+ 'disabled' => $config['disabled'],
+ 'availableNativeFields' => $availableNativeFields,
+ 'availableCustomFields' => $availableCustomFields,
+ 'availableUiElements' => $availableUiElements,
+ ])->render();
}
public function layoutElementSelectorHtml(
@@ -341,38 +290,20 @@ private function setLayoutOnElements(array $elements, FieldLayout $fieldLayout):
}
}
- private function fldTabHtml(FieldLayoutTab $tab, bool $customizable, bool $disabled): string
+ public function fldTabHtml(FieldLayoutTab $tab, bool $customizable, bool $disabled): string
{
- return
- Html::beginTag('div', [
- 'class' => 'fld-tab',
- 'data' => [
- 'uid' => $tab->uid,
- ],
- ]).
- Html::beginTag('div', ['class' => 'tabs']).
- Html::tag('div', $tab->labelHtml(), [
- 'class' => array_filter([
- 'tab',
- 'sel',
- $customizable ? 'draggable' : null,
- ]),
- ]).
- Html::endTag('div'). // .tabs
- Html::beginTag('div', ['class' => 'fld-tabcontent']).
- implode('', array_map(fn (FieldLayoutElement $element) => $this->layoutElementSelectorHtml($element, false), $tab->getElements())).
- Html::button(t('Add'), [
- 'class' => ['btn', 'add', 'icon', 'dashed', 'fullwidth', 'fld-add-btn'],
- 'disabled' => $disabled,
- ]).
- Html::endTag('div'). // .fld-tabcontent
- Html::endTag('div'); // .fld-tab
+ return view('c::forms.fld.tab', [
+ 'designer' => $this,
+ 'tab' => $tab,
+ 'customizable' => $customizable,
+ 'disabled' => $disabled,
+ ])->render();
}
/**
* @param BaseField[] $groupFields
*/
- private function fldFieldSelectorsHtml(string $groupName, array $groupFields, FieldLayout $fieldLayout): string
+ public function fldFieldSelectorsHtml(string $groupName, array $groupFields, FieldLayout $fieldLayout): string
{
$showGroup = Collection::make($groupFields)->contains(
fn (BaseField $field) => $this->showFldFieldSelector($fieldLayout, $field),
@@ -412,7 +343,7 @@ private function showFldFieldSelector(FieldLayout $fieldLayout, BaseField $field
});
}
- private function showFldUiElementSelector(FieldLayout $fieldLayout, FieldLayoutElement $uiElement): bool
+ public function showFldUiElementSelector(FieldLayout $fieldLayout, FieldLayoutElement $uiElement): bool
{
if ($uiElement->isMultiInstance()) {
return true;
diff --git a/src/FieldLayout/LayoutElements/BaseField.php b/src/FieldLayout/LayoutElements/BaseField.php
index 1f31a872974..f49e4c2ffe9 100644
--- a/src/FieldLayout/LayoutElements/BaseField.php
+++ b/src/FieldLayout/LayoutElements/BaseField.php
@@ -185,7 +185,8 @@ protected function selectorInnerHtml(): string
$icon = $this->selectorIcon();
$indicatorHtml = implode('', array_map(fn (array $indicator) => Html::tag('div', Icons::svg($indicator['icon'], altText: $indicator['label']), [
- 'class' => array_filter(['cp-icon', 'puny', $indicator['iconColor'] ?? null]),
+ 'class' => ['cp-icon', 'w-[0.75em]', 'text-fill-normal'],
+ 'data-color' => $indicator['iconColor'] ?? null,
'title' => $indicator['label'],
]), $this->selectorIndicators()));
@@ -204,14 +205,14 @@ protected function selectorInnerHtml(): string
'class' => 'fld-attribute',
]).
Html::tag('div', $this->attribute(), [
- 'class' => ['smalltext', 'light', 'code', 'fld-attribute-label'],
+ 'class' => ['text-xs', 'font-light', 'font-mono', 'fld-attribute-label'],
'title' => $this->attribute(),
]).
Html::endTag('div'); // .fld-attribute
if ($indicatorHtml) {
$innerHtml .= Html::tag('div', $indicatorHtml, [
- 'class' => ['fld-field-indicators', 'flex', 'flex-nowrap', 'gap-xs'],
+ 'class' => ['fld-field-indicators', 'flex', 'flex-nowrap', 'gap-2'],
]);
}
@@ -221,7 +222,7 @@ protected function selectorInnerHtml(): string
if ($icon) {
return Html::tag('div', Icons::svg($icon), [
- 'class' => ['cp-icon', 'medium'],
+ 'class' => ['fld-element-icon'],
]).$html;
}
diff --git a/src/FieldLayout/LayoutElements/HorizontalRule.php b/src/FieldLayout/LayoutElements/HorizontalRule.php
index 9fa1985cd57..9c3561fd0d5 100644
--- a/src/FieldLayout/LayoutElements/HorizontalRule.php
+++ b/src/FieldLayout/LayoutElements/HorizontalRule.php
@@ -35,7 +35,7 @@ public function selectorHtml(): string
return <<
-
+
$label
$indicatorHtml
diff --git a/src/FieldLayout/LayoutElements/LineBreak.php b/src/FieldLayout/LayoutElements/LineBreak.php
index c93a710cd91..d5da595c642 100644
--- a/src/FieldLayout/LayoutElements/LineBreak.php
+++ b/src/FieldLayout/LayoutElements/LineBreak.php
@@ -35,7 +35,7 @@ public function selectorHtml(): string
return <<
-
+
$label
$indicatorHtml
diff --git a/src/Http/Controllers/Settings/EntryTypesController.php b/src/Http/Controllers/Settings/EntryTypesController.php
index a02b41edc60..e699d36b624 100644
--- a/src/Http/Controllers/Settings/EntryTypesController.php
+++ b/src/Http/Controllers/Settings/EntryTypesController.php
@@ -5,6 +5,7 @@
namespace CraftCms\Cms\Http\Controllers\Settings;
use CraftCms\Cms\Config\GeneralConfig;
+use CraftCms\Cms\Cp\FieldLayoutDesigner\FieldLayoutDesigner;
use CraftCms\Cms\Cp\Html\ElementHtml;
use CraftCms\Cms\Entry\Data\EntryType;
use CraftCms\Cms\Entry\Elements\Entry;
@@ -48,6 +49,7 @@ public function __construct(
Fields $fields,
GeneralConfig $generalConfig,
private readonly EntryTypes $entryTypes,
+ private readonly FieldLayoutDesigner $fieldLayoutDesigner,
) {
$this->readOnly = ! $generalConfig->allowAdminChanges;
@@ -134,15 +136,16 @@ private function entryTypeProps(EntryType $entryType, bool $brandNew): array
}
}
- // The field layout designer UI is deferred; round-trip the current config so saves preserve the layout.
- $fieldLayoutConfig = [
- 'uid' => $fieldLayout->uid,
- ...(array) $fieldLayout->getConfig(),
+ // Render just the designer markup (with `autoBoot: false`, so it doesn't queue its
+ // own boot JS).
+ $fieldLayoutDesigner = [
+ 'html' => $this->fieldLayoutDesigner->html($fieldLayout, [
+ 'disabled' => $this->readOnly,
+ 'withGeneratedFields' => true,
+ 'withCardViewDesigner' => true,
+ 'autoBoot' => false,
+ ]),
];
- if ($fieldLayout->id) {
- $fieldLayoutConfig['id'] = $fieldLayout->id;
- }
- $fieldLayoutConfig['type'] = Entry::class;
$translationMethodOptions = [
['value' => TranslationMethod::None->value, 'label' => t('Not translatable')],
@@ -169,7 +172,7 @@ private function entryTypeProps(EntryType $entryType, bool $brandNew): array
'slugTranslationKeyFormat' => $entryType->slugTranslationKeyFormat,
'showStatusField' => (bool) $entryType->showStatusField,
],
- 'fieldLayoutConfig' => $fieldLayoutConfig,
+ 'fieldLayoutDesigner' => $fieldLayoutDesigner,
'translationMethodOptions' => $translationMethodOptions,
'typeName' => Entry::displayName(),
'lowerTypeName' => Entry::lowerDisplayName(),
From 15950d9219197e10e69492a1881f2fc00aaaf71f Mon Sep 17 00:00:00 2001
From: Brian Hanson
Date: Thu, 18 Jun 2026 15:46:14 -0500
Subject: [PATCH 3/3] More semantic checking
---
packages/craftcms-legacy/garnish/src/Garnish.js | 9 +++------
1 file changed, 3 insertions(+), 6 deletions(-)
diff --git a/packages/craftcms-legacy/garnish/src/Garnish.js b/packages/craftcms-legacy/garnish/src/Garnish.js
index 999c902b026..43f1e19f6b2 100644
--- a/packages/craftcms-legacy/garnish/src/Garnish.js
+++ b/packages/craftcms-legacy/garnish/src/Garnish.js
@@ -1049,8 +1049,7 @@ function initialize() {
// Prevent buttons from getting focus on click
if (
e.currentTarget.nodeName === 'BUTTON' ||
- e.currentTarget.nodeName === 'CRAFT-BUTTON' ||
- e.currentTarget.role === 'button'
+ e.currentTarget.getAttribute?.('role') === 'button'
) {
e.preventDefault();
}
@@ -1076,8 +1075,7 @@ function initialize() {
if (
e.currentTarget.nodeName === 'BUTTON' ||
- e.currentTarget.nodeName === 'CRAFT-BUTTON' ||
- e.currentTarget.role === 'button'
+ e.currentTarget.getAttribute?.('role') === 'button'
) {
e.preventDefault();
}
@@ -1101,8 +1099,7 @@ function initialize() {
if (
e.currentTarget.nodeName === 'BUTTON' ||
- e.currentTarget.nodeName === 'CRAFT-BUTTON' ||
- e.currentTarget.role === 'button'
+ e.currentTarget.getAttribute?.('role') === 'button'
) {
e.preventDefault();
}