Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
86 changes: 83 additions & 3 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 @@ -649,6 +716,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
5 changes: 3 additions & 2 deletions packages/craftcms-cp/src/components/indicator/indicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ export default class CraftIndicator extends LitElement {
variantsStyles,
css`
.indicator {
--_background: var(--background, var(--c-color-fill-loud));
display: inline-flex;
aspect-ratio: 1;
width: var(--c-indicator-size, 0.5em);
border-radius: var(--c-radius-full);
color: var(--c-color-on-loud);
background-color: var(--c-color-fill-loud);
background: var(--_background);
border: 1px solid var(--c-color-border-loud);
}

Expand All @@ -26,7 +27,7 @@ export default class CraftIndicator extends LitElement {
];

@property({reflect: true})
variant: VariantKey | 'empty' = Variant.Default;
variant: VariantKey | 'custom' | 'empty' = Variant.Default;

@property()
label: string | null = null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {css} from 'lit';

export default css`
:host {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--c-spacing-sm);
width: 100%;
cursor: pointer;
padding-inline: var(--c-input-spacing-inline);
min-height: calc(var(--c-input-height, var(--c-size-control-md)) - 2px);
font: inherit;
}

:host([disabled]) {
cursor: not-allowed;
opacity: 0.5;
}

#content-wrapper {
position: relative;
pointer-events: none;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.indicator {
flex: 0 0 auto;
font-size: 0.8em;
}
`;
21 changes: 21 additions & 0 deletions packages/craftcms-cp/src/components/select-rich/select-invoker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {LionSelectInvoker} from '@lion/ui/select-rich.js';
import {html} from 'lit';
import styles from './select-invoker.styles.js';
import '../icon/icon.js';

export default class CraftSelectInvoker extends LionSelectInvoker {
static override get styles() {
return [...super.styles, styles];
}

override _afterTemplate() {
return html`${!this.singleOption
? html`<craft-icon
class="indicator"
name="chevron-down"
></craft-icon>`
: ''}`;
}
}

// Not globally registered — used only as a scoped element within CraftSelectRich
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type {Meta, StoryObj} from '@storybook/web-components-vite';
import {html} from 'lit';
import './select-rich.js';
import '../option/option.js';
import '../indicator/indicator.js';

const meta = {
title: 'Controls/Select Rich',
component: 'craft-select-rich',
args: {},
render: function () {
return html`
<craft-select-rich label="Favorite Fruit" name="fruit">
<craft-option .choiceValue=${'apple'}>Apple</craft-option>
<craft-option .choiceValue=${'banana'}>Banana</craft-option>
<craft-option .choiceValue=${'cherry'}>Cherry</craft-option>
<craft-option .choiceValue=${'grape'}>Grape</craft-option>
<craft-option .choiceValue=${'mango'}>Mango</craft-option>
</craft-select-rich>
`;
},
} satisfies Meta<any>;

export default meta;
type Story = StoryObj<any>;

export const Default: Story = {
args: {},
};

export const RichOptions: Story = {
args: {},
render: function () {
return html`
<craft-select-rich label="System Status" name="status">
<craft-option .choiceValue=${'online'}>
<div class="flex items-center gap-1">
<craft-indicator variant="success"></craft-indicator>
<span>Online</span>
</div>
</craft-option>
<craft-option .choiceValue=${'maintenance'}>
<div class="flex items-center gap-1">
<craft-indicator variant="warning"></craft-indicator>
<span>Maintenance</span>
</div>
</craft-option>
<craft-option .choiceValue=${'offline'}>
<div class="flex items-center gap-1">
<craft-indicator variant="danger"></craft-indicator>
<span>Offline</span>
</div>
</craft-option>
</craft-select-rich>
`;
},
};

export const Small: Story = {
args: {},
render: function () {
return html`
<craft-select-rich label="Size" name="size" small>
<craft-option .choiceValue=${'sm'}>Small</craft-option>
<craft-option .choiceValue=${'md'}>Medium</craft-option>
<craft-option .choiceValue=${'lg'}>Large</craft-option>
</craft-select-rich>
`;
},
};

export const WithHints: Story = {
args: {},
render: function () {
return html`
<craft-select-rich label="Section" name="section">
<craft-option .choiceValue=${'blog'} hint="12 entries"
>Blog</craft-option
>
<craft-option .choiceValue=${'news'} hint="4 entries"
>News</craft-option
>
<craft-option .choiceValue=${'docs'} hint="89 entries"
>Documentation</craft-option
>
</craft-select-rich>
`;
},
};
Loading
Loading