Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
130b1a4
Add craft-field web component
brianjhanson Jul 9, 2026
f530b26
Add CP UI component system
brianjhanson Jul 9, 2026
51174c1
Render FormFields and lightswitch.twig through UI components
brianjhanson Jul 9, 2026
2d74885
Route lightswitch.twig through the PHP config mapper
brianjhanson Jul 9, 2026
0d584cc
Port button and buttonGroup to UI components
brianjhanson Jul 9, 2026
a2320c2
Port checkbox to a UI component
brianjhanson Jul 9, 2026
5d3ccd3
Always render an explicit type on craft-button
brianjhanson Jul 9, 2026
d24e682
Deprecate mismatched legacy config keys in the component mappers
brianjhanson Jul 9, 2026
5acfabb
Merge branch 'feature/inertia-field-edit' of github.com:craftcms/cms …
brianjhanson Jul 9, 2026
6d9de97
Clean things up a bit
brianjhanson Jul 9, 2026
400557a
Checkboxes and radios
brianjhanson Jul 10, 2026
1c861f9
Fix field width
brianjhanson Jul 10, 2026
8ed0b90
BaseOptions cleanup
brianjhanson Jul 10, 2026
3c49ba0
Implement radio group
brianjhanson Jul 10, 2026
241861b
Give action menu search capabilities
brianjhanson Jul 13, 2026
ef3a737
Fix AppLayout bug
brianjhanson Jul 13, 2026
69ab137
garnish: add nearestSibling, closestRegistered, and toHtmlElement utils
brianjhanson Jul 14, 2026
56c63ef
craft-reorder-button: add horizontal orientation
brianjhanson Jul 14, 2026
6c6159b
craft-action-menu: add keyboard-navigation tests
brianjhanson Jul 14, 2026
c6c2a8f
Render chip and card action menus as craft-action-menu
brianjhanson Jul 14, 2026
91f0a0a
Port ComponentSelectInput to <craft-component-select>
brianjhanson Jul 14, 2026
5d63a4a
Port GroupedEntryTypeManager to <craft-entry-type-manager>
brianjhanson Jul 14, 2026
548c023
Fix chip list spacing for iconless chips
brianjhanson Jul 14, 2026
c7bcbb1
Render legacy field-settings islands in Inertia
brianjhanson Jul 14, 2026
f7df03e
docs: document the CP JS module-port pattern
brianjhanson Jul 14, 2026
865ef03
listbox: use toHtmlElement from garnish compat
brianjhanson Jul 14, 2026
6f97075
craft-disclosure: extend LionCollapsible
brianjhanson Jul 14, 2026
c199c3a
Convert field-settings Advanced toggles to craft-disclosure
brianjhanson Jul 14, 2026
68df108
Sync checkbox host/input state for the legacy All handler
brianjhanson Jul 14, 2026
fd9762c
Restore multi-select to craft-component-select
brianjhanson Jul 14, 2026
5e3bdc9
Add cross-group chip drag to entry-type manager
brianjhanson Jul 14, 2026
c91ee9d
Port entry-type override editor to web-component path
brianjhanson Jul 14, 2026
3d95872
Fix icons
brianjhanson Jul 14, 2026
88353df
Improve spacing
brianjhanson Jul 14, 2026
0801091
Migrate entryTypeSelect off legacy Craft.EntryTypeSelectInput
brianjhanson Jul 14, 2026
f432f00
Give web-component properties explicit kebab-case attributes
brianjhanson Jul 15, 2026
c756064
Add PHP<->web-component drift-detection test
brianjhanson Jul 15, 2026
104e5c0
Remove extra `FormFields::buttonHtml` layer
brianjhanson Jul 15, 2026
af11eaf
Remove extra FormFields::*Html layer for component-backed fields
brianjhanson Jul 15, 2026
4902725
Merge branch '6.x' into feature/cp-ui-components
brianjhanson Jul 15, 2026
f9af6a4
Fix phpstan
brianjhanson Jul 15, 2026
5d58722
Remove console.log
brianjhanson Jul 15, 2026
227852c
Replace `FormFields::*FromConfig` calls within core
brianjhanson Jul 15, 2026
df18d1a
Add deprecation warnings mostly to keep track of things
brianjhanson Jul 15, 2026
a2450db
Clean house a bit
brianjhanson Jul 15, 2026
e9ff05d
Fix typo
brianjhanson Jul 15, 2026
49a6428
Better button styles
brianjhanson Jul 15, 2026
d71b9ce
Accept float values
brianjhanson Jul 15, 2026
e996c09
Fix typo
brianjhanson Jul 15, 2026
c77bbff
Update yii2-adapter tests
brianjhanson Jul 15, 2026
f59b290
Fix globby ... again?
brianjhanson Jul 15, 2026
1f8b8af
Generate CEM before tests run
brianjhanson Jul 16, 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
7 changes: 7 additions & 0 deletions .github/workflows/js-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ jobs:
if: steps.cache.outputs.cache-hit != 'true'
run: npm run build:cp

# `build:cp` does not produce the Custom Elements Manifest (only the
# Storybook pre-hooks do), so generate it explicitly — it lands in the
# packages/craftcms-cp/dist cache path above for the downstream jobs.
- name: Build Custom Elements Manifest
if: steps.cache.outputs.cache-hit != 'true'
run: cd ./packages/craftcms-cp && npm run build:manifest

- name: Generate TypeScript types
if: steps.cache.outputs.cache-hit != 'true'
run: npm run generate:types
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/laravel-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,23 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4

# ComponentManifestDriftTest needs the Custom Elements Manifest, which is
# gitignored (packages/craftcms-cp/dist). The cache key hashes everything
# the analyzer reads, so a hit is never stale; the cache itself is shared
# repo-wide, so whichever workflow generates it first serves the rest. On
# a miss the pinned npx run (~seconds) needs no npm install.
- name: Restore Custom Elements Manifest
id: cem
uses: actions/cache@v4
with:
path: packages/craftcms-cp/dist/custom-elements.json
key: cem-${{ hashFiles('packages/craftcms-cp/src/components/**', 'packages/craftcms-cp/custom-elements-manifest.config.mjs', 'packages/craftcms-cp/package.json', 'packages/craftcms-cp/tsconfig.json') }}

- name: Generate Custom Elements Manifest
if: steps.cem.outputs.cache-hit != 'true'
working-directory: packages/craftcms-cp
run: npx --yes @custom-elements-manifest/analyzer@0.11.0 analyze

- name: Run tests
uses: ./.github/actions/run-tests
with:
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ This is a large codebase with some large files. Search narrowly before reading f

The Control Panel contains both legacy Twig/jQuery surfaces and newer Inertia + Vue screens. Prefer `@craftcms/cp` components when building UI, and match whichever surface the surrounding feature already uses.

When porting behavior out of the legacy jQuery bundle (`packages/craftcms-legacy/cp/src/js/*.js`) into modern TypeScript, follow the shared module pattern documented in `resources/js/modules/README.md` — a logic class (`<name>.ts` on `@craftcms/garnish` `Base`), a `ControllerElement` custom element (`<name>.ce.ts`), an instance-registry `support.ts` WeakMap, and an `index.ts` shim that registers the element and assigns the legacy `window.Craft.*` global. Note the source-vs-`dist` gotcha in that README: after editing `packages/craftcms-garnish/src` (or `@craftcms/cp`), rebuild the package's `dist` or `npm run typecheck` won't see the change.

## Adapter Work

`yii2-adapter` is compatibility code, not the implementation path for new core behavior. If you need to add adapter classes, follow its Composer autoload mapping. Do not put general adapter classes in `yii2-adapter/lib/`; that area is for vendored or library-style code.
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

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

71 changes: 71 additions & 0 deletions packages/craftcms-cp/scripts/generate-vue-wrappers.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,20 @@ const GROUP_COMPONENTS = [
},
];

/**
* Chrome-only components with no model value of their own. The wrapper only
* bridges an `error` prop into the feedback slot; everything else (including
* the wrapped control) passes through via $attrs and the default slot.
*/
const CHROME_COMPONENTS = [
{
tagName: 'craft-field',
className: 'CraftField',
fileName: 'CraftField',
importPath: '../components/field/field',
},
];

// ─── Template Generators ────────────────────────────────────────────────────

function generateSlotForwards(slots) {
Expand Down Expand Up @@ -382,6 +396,36 @@ function generateGroupWrapper(component) {
`;
}

function generateChromeWrapper(component) {
return `<!--
Auto-generated Vue wrapper for <${component.tagName}>
Chrome-only shell (no v-model); bridges an error prop into the feedback slot.
Generated by: scripts/generate-vue-wrappers.js
-->
<script setup lang="ts">
defineOptions({
name: '${component.className}',
});

defineProps<{
error?: null | string
}>()
</script>

<template>
<${component.tagName} v-bind="$attrs">
<slot></slot>

<div slot="feedback">
<ul class="error-list" v-if="error">
<li>{{ error }}</li>
</ul>
</div>
</${component.tagName}>
</template>
`;
}

// ─── Declaration File Generators ────────────────────────────────────────────

/**
Expand Down Expand Up @@ -419,6 +463,19 @@ export default _default;
`;
}

function generateChromeDeclaration(component) {
return `/**
* Auto-generated type declaration for ${component.fileName}.vue
* Generated by: scripts/generate-vue-wrappers.js
*/
import type {DefineComponent} from 'vue';
declare const _default: DefineComponent<{
error?: string | null;
}>;
export default _default;
`;
}

function generateGroupDeclaration(component) {
return `/**
* Auto-generated type declaration for ${component.fileName}.vue
Expand Down Expand Up @@ -447,6 +504,8 @@ const ALL_COMPONENTS = [
...CHECKED_COMPONENTS,
// Form components (groups)
...GROUP_COMPONENTS,
// Field chrome shell
...CHROME_COMPONENTS,
// Choice inputs used inside groups (not typically needing v-model wrappers)
{
tagName: 'craft-radio',
Expand Down Expand Up @@ -689,6 +748,18 @@ export default function main() {
count++;
}

// Generate chrome-only wrappers
for (const component of CHROME_COMPONENTS) {
const content = generateChromeWrapper(component);
const filePath = resolve(VUE_DIR, `${component.fileName}.vue`);
writeFileSync(filePath, content);
const declContent = generateChromeDeclaration(component);
const declPath = resolve(VUE_DIR, `${component.fileName}.vue.d.ts`);
writeFileSync(declPath, declContent);
console.log(` Generated: ${VUE_DIR}/${component.fileName}.vue`);
count++;
}

console.log(`\n ${count} Vue wrappers generated in ${VUE_DIR}/`);

// Generate type augmentations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export default css`
display: contents;
}

:host([hidden]) {
display: none;
}

.action-item {
border-color: var(--c-color-border-quiet, transparent);
color: var(--c-color-on-quiet, inherit);
Expand Down
16 changes: 14 additions & 2 deletions packages/craftcms-cp/src/components/action-item/action-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,24 @@ import {Variant, type VariantKey} from '@src/constants/variants';
*/
export default class CraftActionItem extends LitElement {
static override styles = [variantsStyles, styles];

/**
* Delegate focus into the shadow root, so `host.focus()` (used by
* `craft-action-menu`'s keyboard navigation) lands on the internal
* button/anchor and native Enter/Space activation keeps working.
*/
static override shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};

@property() icon: string | null = null;
/**
* Optional Craft color name used to tint the item's icon. The icon renders
* with `currentColor`, so we set the icon element's `color` to a matching
* `--c-color-*` token.
*/
@property() iconColor: string | null = null;
@property({attribute: 'icon-color'}) iconColor: string | null = null;
@property() href: string | null = null;
@property({type: Boolean}) disabled: boolean = false;
@property({reflect: true}) variant: VariantKey = Variant.Neutral;
Expand All @@ -34,7 +45,8 @@ export default class CraftActionItem extends LitElement {
@property() type: 'button' | 'checkbox' = 'button';
@property({type: Object}) action: BaseAction | null = null;
@property({type: Object}) feedback: ActionFeedback | null = null;
@property({type: Number}) feedbackDuration: number = 1000;
@property({type: Number, attribute: 'feedback-duration'})
feedbackDuration: number = 1000;
@property() confirm: string | null = null;

@state() private state: AsyncState = AsyncStates.Idle;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {html} from 'lit';
import {ref} from 'lit/directives/ref.js';

import '../action-item/action-item.js';
import '../button/button.js';
import '../icon/icon.js';
import './action-menu.js';
import type CraftActionMenu from './action-menu.js';
import type {ActionMenuItem} from './action-menu.types.js';
Expand Down Expand Up @@ -112,6 +114,103 @@ export const DataDrivenWithCustomInvoker: Story = {
</craft-action-menu>`,
};

/**
* `searchable` adds a filter input to the top of the menu (slot-based mode).
* Items match on their visible text plus a `data-keywords` attribute — the
* channel for hidden search terms. Try typing "plainText": it matches the
* "Plain Text" item by its handle even though the visible label doesn't
* contain it.
*/
export const Searchable: Story = {
render: () =>
html`<craft-action-menu searchable>
<craft-button type="button" slot="invoker" appearance="secondary">
Add a field
</craft-button>

<div slot="content">
<craft-action-item icon="pen" data-keywords="plainText">
Plain Text
</craft-action-item>
<craft-action-item icon="caret-down" data-keywords="dropdown optedList">
Dropdown
</craft-action-item>
<craft-action-item icon="calendar" data-keywords="dateTime">
Date
</craft-action-item>
<craft-action-item icon="lightbulb" data-keywords="lightswitch boolean">
Lightswitch
</craft-action-item>
<craft-action-item icon="image" data-keywords="assets">
Assets
</craft-action-item>
</div>
</craft-action-menu>`,
};

/**
* `searchable` in data-driven mode: the `keywords` descriptor field is
* rendered onto the generated `craft-action-item` as `data-keywords`, so
* hidden terms (e.g. handles) match identically in both modes.
*/
export const SearchableDataDriven: Story = {
render: () => {
const actions: ActionMenuItem[] = [
{label: 'Plain Text', icon: 'pen', keywords: 'plainText'},
{label: 'Dropdown', icon: 'caret-down', keywords: 'dropdown optedList'},
{label: 'Date', icon: 'calendar', keywords: 'dateTime'},
{
label: 'Lightswitch',
icon: 'lightbulb',
keywords: 'lightswitch boolean',
},
{label: 'Assets', icon: 'image', keywords: 'assets'},
];

return html`<craft-action-menu
searchable
.actions="${actions}"
label="Add a field"
></craft-action-menu>`;
},
};

/**
* Search never overrides consumer-controlled visibility: items hidden with the
* `hidden` attribute stay hidden even when they match the query (filtering
* uses a separate `data-search-hidden` mechanism). This is how a consumer like
* component-select keeps already-selected options out of the menu while the
* user searches. "Dropdown" below is consumer-hidden — searching for it (or
* its keywords) never reveals it.
*/
export const SearchableWithHiddenItems: Story = {
render: () =>
html`<craft-action-menu searchable>
<craft-button type="button" slot="invoker" appearance="secondary">
Add a field
</craft-button>

<div slot="content">
<craft-action-item icon="pen" data-keywords="plainText">
Plain Text
</craft-action-item>
<craft-action-item
icon="caret-down"
data-keywords="dropdown optedList"
hidden
>
Dropdown
</craft-action-item>
<craft-action-item icon="calendar" data-keywords="dateTime">
Date
</craft-action-item>
<craft-action-item icon="image" data-keywords="assets">
Assets
</craft-action-item>
</div>
</craft-action-menu>`,
};

/**
* Data-driven `display` item: the descriptor takes a DOM `Node` (or a function
* returning one), not a framework component.
Expand Down
Loading
Loading