Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8618aad
Initial refactor of element index views to inertia
brianjhanson May 28, 2026
9585dfe
Improve AGENTS.md
brianjhanson May 28, 2026
68506cb
Use date columns
brianjhanson May 28, 2026
6859c78
Add the status filter
brianjhanson May 30, 2026
a639854
Add status filter
brianjhanson May 30, 2026
5239304
Refactor action menu into popover
brianjhanson Jun 1, 2026
b1b550f
Allow button groups to behave like radios
brianjhanson Jun 1, 2026
d4342ec
Allow passing more attributes to options from checkbox groups
brianjhanson Jun 1, 2026
d8a022a
Add our own useStorage proxy
brianjhanson Jun 1, 2026
5b4aeca
Trying another approcah
brianjhanson Jun 1, 2026
f39f60c
Add swatch, custom, and volume support to craft-indicator
brianjhanson Jun 1, 2026
8c9ef86
Update button-group docs
brianjhanson Jun 2, 2026
c3733b2
Merge branch '6.x' of github.com:craftcms/cms into brian/cms-2148-cp-…
brianjhanson Jun 4, 2026
3608fd9
Build
brianjhanson Jun 4, 2026
a07bffb
Round out the badge component
brianjhanson Jun 4, 2026
aa6c239
Refactor chip to use status-badge
brianjhanson Jun 4, 2026
0e741b8
Merge branch 'feature/ui-status-badge' of github.com:craftcms/cms int…
brianjhanson Jun 4, 2026
927c1dd
Merge branch '6.x' of github.com:craftcms/cms into brian/cms-2148-cp-…
brianjhanson Jun 5, 2026
f9f477e
Merge branch '6.x' of github.com:craftcms/cms into brian/cms-2148-cp-…
brianjhanson Jun 5, 2026
543fb1c
Merge branch 'brian/fix-web-component-camelcase-prop-bindings' of git…
brianjhanson Jun 5, 2026
fe7b054
Merge branch '6.x' of github.com:craftcms/cms into brian/cms-2148-cp-…
brianjhanson Jun 6, 2026
45b5c8a
[WIP] Entry index
brianjhanson Jun 6, 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
65 changes: 63 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,38 @@ New core work should be Laravel-first. Do not add Yii dependencies to `src/`; pu

This is a large codebase with some large files. Search narrowly before reading full files.

## Commands

### PHP

```bash
composer tests # Run all Pest tests
composer tests-adapter # Run yii2-adapter tests only
./vendor/bin/pest path/to/TestFile.php # Run a single test file
./vendor/bin/pest --filter "test description" # Run tests matching a name
composer fix-cs # Run Rector + Pint + ECS (auto-fixes code style)
composer phpstan # Run PHPStan static analysis (level 5)
composer ci # Full CI pipeline: pint, rector, phpstan, tests, tests-adapter
composer serve # Start the testbench dev server
```

### Frontend

```bash
npm run dev # Vite dev server (HMR) for the Inertia/Vue CP
npm run build # Production Vite build (cp.ts + legacy.ts + cp.css)
npm run build:all # Build legacy bundles + CP component package + Vite
npm run dev:bundles # Webpack dev watch for legacy jQuery bundles
npm run dev:cp # Dev build for the @craftcms/cp component package
npm run build:cp # Production build for the @craftcms/cp component package
npm run lint # ESLint + Stylelint + TypeScript type-check
npm run typecheck # TypeScript type-check only (vue-tsc)
npm run test:cp # Vitest tests for the @craftcms/cp package
```

> **Note:** `@craftcms/cp` must be built (`npm run build:cp`) before building or running the main Vite app if you've
> made changes to it.

## Testing

- Pest tests using `tests/TestCase.php` or `yii2-adapter/tests-laravel/TestCase.php` share a database lock. If another process has the lock, the next process will wait and print `Another Pest process is already using the shared test database. Waiting for the lock...`.
Expand All @@ -28,9 +60,38 @@ This is a large codebase with some large files. Search narrowly before reading f
- Laravel events are the native event system. Yii event constants and bridge registration belong in `yii2-adapter` for compatibility only.
- Services that should be singletons generally use Laravel's `#[Singleton]` or `#[Scoped]` attribute.

## Frontend
## Frontend Architecture

The CP has two parallel rendering stacks that are actively being consolidated:

**Inertia/Vue (new):** `resources/js/cp.ts` is the entrypoint. Inertia pages live in `resources/js/pages/`, shared Vue
components in `resources/js/common/`. `HandleInertiaRequests` middleware provides shared CP config, navigation, and
global props to all Inertia pages. The root Blade template is `resources/views/app.blade.php`.

**Legacy jQuery (old):** `resources/js/legacy.ts` loads the old surface. The individual jQuery modules live in
`packages/craftcms-legacy/` and are bundled with webpack (separate from Vite). Pages still on this stack return `view()`
from their controllers.

**`CpScreenResponse`** is an intermediate state used by pages mid-migration: the outer CP shell is rendered via Inertia,
but the inner content is PHP-rendered HTML injected into the page. Controllers returning `CpScreenResponse` are
partially migrated; full migration means converting the inner form to a Vue component and switching to
`Inertia::render()`.

**Packages:**

- `packages/craftcms-cp` — the `@craftcms/cp` component library (Web Components built on Lit/WebAwesome). Imported as
`@craftcms/cp` in Vue pages. Has its own build (`npm run build:cp`) and Vitest tests (`npm run test:cp`).
- `packages/craftcms-legacy` — webpack-bundled jQuery modules used by legacy CP surfaces.

**TypeScript types** for PHP classes are auto-generated via `spatie/laravel-typescript-transformer` and written to
`resources/js/generated/`. This runs automatically on `vite dev`/`vite build` when relevant PHP files change; run
`./vendor/bin/testbench typescript:transform` manually if needed.

**Wayfinder** generates typed route URL helpers into `resources/js/` from Laravel routes. Regenerate with
`./vendor/bin/testbench wayfinder:generate`.

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.
**Custom elements** (anything with a hyphen in the tag name) are treated as native web components by the Vue compiler —
they pass through to the browser without Vue trying to resolve them as Vue components.

## Adapter Work

Expand Down
1 change: 1 addition & 0 deletions packages/craftcms-cp/scripts/generate-colors.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const availableColors = [
'white',
'gray',
'black',
'slate',
];

const semanticColors = {
Expand Down
91 changes: 83 additions & 8 deletions packages/craftcms-cp/scripts/generate-vue-wrappers.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,18 @@ const GROUP_COMPONENTS = [
},
];

/**
* Select rich component — uses modelValue like VALUE_COMPONENTS but needs
* a custom wrapper template for additional behaviour.
*/
const SELECT_RICH_COMPONENT = {
tagName: 'craft-select-rich',
className: 'CraftSelectRich',
fileName: 'CraftSelectRich',
modelType: 'string',
importPath: '../components/select-rich/select-rich',
};

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

function generateSlotForwards(slots) {
Expand Down Expand Up @@ -237,7 +249,6 @@ function generateValueWrapper(component) {

<template>
<${component.tagName}
v-bind="$attrs"
.modelValue="model"
@model-value-changed="model = ($event.target as ${component.className})?.modelValue"
:has-feedback-for="error ? 'error' : ''"
Expand Down Expand Up @@ -276,7 +287,6 @@ function generateCheckedWrapper(component) {

<template>
<${component.tagName}
v-bind="$attrs"
.checked="model"
@model-value-changed="model = ($event.target as ${component.className})?.checked"
:has-feedback-for="error ? 'error' : ''"
Expand Down Expand Up @@ -315,7 +325,6 @@ function generateGroupWrapper(component) {

<template>
<${component.tagName}
v-bind="$attrs"
.modelValue="model"
@model-value-changed="model = ($event.target as ${component.className})?.modelValue"
:has-feedback-for="error ? 'error' : ''"
Expand All @@ -332,6 +341,58 @@ function generateGroupWrapper(component) {
`;
}

function generateSelectRichWrapper(component) {
return `<!--
Auto-generated Vue wrapper for <${component.tagName}>
Provides v-model support by bridging Vue's modelValue to Lion UI's modelValue property.
Generated by: scripts/generate-vue-wrappers.js
-->
<script setup lang="ts">
import type ${component.className} from '${component.importPath}.ts.mjs';

export interface SelectRichOption {
label: string;
value: string | number;
}

defineOptions({
name: '${component.className}',
});

const model = defineModel<${component.modelType}>();

defineProps<{
error?: null | string
options?: SelectRichOption[]
}>()
</script>

<template>
<${component.tagName}
.modelValue="model"
@model-value-changed="model = ($event.target as ${component.className})?.modelValue"
:has-feedback-for="error ? 'error' : ''"
>
<craft-option
v-for="option in options"
:key="option.value"
.choiceValue="String(option.value)"
>
<slot name="option" :option="option">
{{ option.label }}
</slot>
</craft-option>

<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 @@ -408,6 +469,12 @@ const ALL_COMPONENTS = [
className: 'CraftCheckboxIndeterminate',
importPath: '../components/checkbox-indeterminate/checkbox-indeterminate',
},
// Select rich
{
tagName: 'craft-select-rich',
className: 'CraftSelectRich',
importPath: '../components/select-rich/select-rich',
},
// Display components
{
tagName: 'craft-button',
Expand Down Expand Up @@ -489,11 +556,6 @@ const ALL_COMPONENTS = [
className: 'CraftDialog',
importPath: '../components/dialog/dialog',
},
{
tagName: 'craft-disclosure',
className: 'CraftDisclosure',
importPath: '../components/disclosure/disclosure',
},
{
tagName: 'craft-drawer',
className: 'CraftDrawer',
Expand Down Expand Up @@ -649,6 +711,19 @@ export default function main() {
count++;
}

// Generate select-rich wrapper
{
const component = SELECT_RICH_COMPONENT;
const content = generateSelectRichWrapper(component);
const filePath = resolve(VUE_DIR, `${component.fileName}.vue`);
writeFileSync(filePath, content);
const declContent = generateValueDeclaration(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 @@ -12,7 +12,7 @@ import {
type FeedbackData,
runAction,
} from '@src/actions';
import {Variant, type VariantKey} from '@src/constants/variants';
import {Variant, type VariantValue} from '@src/constants/variants';

/**
* @summary Either a link or button typically used in a menu.
Expand All @@ -22,7 +22,7 @@ export default class CraftActionItem extends LitElement {
@property() icon: string | null = null;
@property() href: string | null = null;
@property({type: Boolean}) disabled: boolean = false;
@property({reflect: true}) variant: VariantKey = Variant.Neutral;
@property({reflect: true}) variant: VariantValue = Variant.Neutral;
@property({type: Boolean}) checked: boolean = false;
@property({type: Boolean}) active: boolean = false;
@property() type: 'button' | 'checkbox' = 'button';
Expand Down
100 changes: 23 additions & 77 deletions packages/craftcms-cp/src/components/action-menu/action-menu.ts
Original file line number Diff line number Diff line change
@@ -1,98 +1,44 @@
import {css, html, LitElement} from 'lit';
import {OverlayMixin, withDropdownConfig} from '@lion/ui/overlays.js';
import {css} from 'lit';
import {queryAssignedElements} from 'lit/decorators.js';
import type CraftActionItem from '@src/components/action-item/action-item';
import {uuid} from '@lion/ui/core.js';
import CraftPopover from '../popover/popover.js';

/**
* @slot - Items to be rendered in the menu.
* An action menu built on craft-popover.
*
* @slot invoker - Element that triggers the menu.
* @slot backdrop - Element that covers the screen when the menu is open.
* @slot content - Content to be rendered inside the menu.
* @slot content - Action items to be rendered in the menu.
*/
export default class CraftActionMenu extends OverlayMixin(LitElement) {
static override styles = css`
::slotted([slot='content']) {
font-size: var(--c-text-base);
font-weight: 400;
display: grid;
gap: var(--c-spacing-xs);
border: 1px solid var(--c-color-neutral-border-quiet);
border-radius: var(--c-radius-md);
background-color: var(--c-surface-overlay);
box-shadow: var(--c-shadow-sm);
padding: var(--c-spacing-sm);
min-width: calc(180rem / 16);
max-width: calc(320rem / 16);
}

::slotted(hr) {
margin: 0;
}
`;

@queryAssignedElements({selector: 'craft-action-item'})
actionItems!: CraftActionItem[];

@queryAssignedElements({slot: 'invoker'})
invokerNodes!: HTMLElement[];
export default class CraftActionMenu extends CraftPopover {
static override styles = [
...CraftPopover.styles,
css`
::slotted([slot='content']) hr {
margin: 0;
}
`,
];

@queryAssignedElements({slot: 'content'})
contentNodes!: HTMLElement[];

private uid: string;

// @ts-ignore
_defineOverlayConfig() {
return {
...withDropdownConfig(),
};
}

private _addEventListeners() {
// Close the menu when an item is clicked.
// @TODO is this good or bad?
this.actionItems.forEach((item) => {
item.addEventListener('click', (e) => {
e.target?.dispatchEvent(new Event('close-overlay', {bubbles: true}));
const content = this.contentNodes[0];
if (!content) return;

content
.querySelectorAll<CraftActionItem>('craft-action-item')
.forEach((item) => {
item.addEventListener('click', () => {
this.opened = false;
});
});
});
}

private _setupInvoker() {
const firstInvoker = this.invokerNodes[0];
if (firstInvoker) {
firstInvoker.setAttribute('id', `invoker-${this.uid}`);
firstInvoker.setAttribute('aria-controls', `content-${this.uid}`);
}
}

private _setupContent() {
const firstContent = this.contentNodes[0];
if (firstContent) {
firstContent.setAttribute('id', `content-${this.uid}`);
firstContent.setAttribute('role', 'none');
}
}

override _setupOverlayCtrl() {
super._setupOverlayCtrl();
this._setupInvoker();
this._setupContent();
}

override firstUpdated() {
this.uid = uuid();
this._addEventListeners();
}

protected override render(): unknown {
return html`
<slot name="invoker"></slot>
<slot name="backdrop"></slot>
<slot name="content"></slot>
`;
}
}

if (!customElements.get('craft-action-menu')) {
Expand Down
Loading
Loading