diff --git a/.gitignore b/.gitignore index 65cf0388633..58dafecc728 100644 --- a/.gitignore +++ b/.gitignore @@ -35,5 +35,6 @@ resources/js/generated/* # Built CMS assets cms-assets/resources/build cms-assets/resources/legacy +cms-assets/resources/hot cms-assets/resources/icons/* !cms-assets/resources/icons/custom-icons diff --git a/.prettierignore b/.prettierignore index dab28459e1b..c36b3089d13 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,7 @@ composer.lock **/dist/* vendor/* .ddev/* +resources/build/* resources/public/* resources/js/actions/* resources/js/routes/* diff --git a/AGENTS.md b/AGENTS.md index 59cb48036af..53b8c996213 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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...`. @@ -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 diff --git a/cms-assets/resources/icons/custom-icons/grip-dots.svg b/cms-assets/resources/icons/custom-icons/grip-dots.svg index 4446a05dde6..e69de29bb2d 100644 --- a/cms-assets/resources/icons/custom-icons/grip-dots.svg +++ b/cms-assets/resources/icons/custom-icons/grip-dots.svg @@ -1 +0,0 @@ - diff --git a/docs/admin-table.md b/docs/admin-table.md index ee2ab4b596b..71f778490fb 100644 --- a/docs/admin-table.md +++ b/docs/admin-table.md @@ -55,7 +55,7 @@ const table = useVueTable({ | `selectable` | `boolean` | `true` | Reserved for future row selection support. | | `readOnly` | `boolean` | — | When `true`, hides reorder handles (used with `reorderable`). | | `layout` | `'auto' \| 'fixed'` | `'auto'` | CSS table layout mode. | -| `spacing` | `TableSpacingValue` | — | Row density: `'compact'`, `'relaxed'`, or `'spacious'`. | +| `spacing` | `TableSpacingValue` | — | Row density: `'compact'` or `'spacious'`. | | `from` | `number` | — | Start index of displayed rows (for "X–Y of Z" display). | | `to` | `number` | — | End index of displayed rows. | | `total` | `number` | — | Total number of items (all pages). | diff --git a/docs/superpowers/plans/2026-07-01-base-element-index.md b/docs/superpowers/plans/2026-07-01-base-element-index.md new file mode 100644 index 00000000000..a0146a666ca --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-base-element-index.md @@ -0,0 +1,1211 @@ +# BaseElementIndex Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Abstract the shared shell/footer/selection from `AdminTable` and `ElementCards` into a page-composed `BaseElementIndex`, extract a bare `DataTable` core, rename the shell CSS to `element-index__*`, and add an ARIA live region plus shift-click and keyboard selection. + +**Architecture:** `content/Index.vue` renders `` (owns the shell chrome, footer, and aria-live region) with either a bare `DataTable` or the bare `ElementCards` grid in its `#body` slot. Shared selection logic (including shift-click range) lives in a `useElementIndexSelection` composable consumed independently by the base and both bodies. A thin `AdminTable` wrapper (= `BaseElementIndex` + `DataTable`) preserves the public API for ~19 standalone callers. + +**Tech Stack:** Vue 3 ` + + + + +``` + +- [ ] **Step 2: Typecheck + lint** + +Run: `npm run typecheck && npx eslint resources/js/modules/elements/components/BaseElementIndex.vue` +Expected: no errors. (If `t()`'s ICU plural signature complains, match the exact `t()` call style already used in `AdminTable.vue`'s `Text` usage — category as third arg — and re-run.) + +- [ ] **Step 3: Commit** + +```bash +git add resources/js/modules/elements/components/BaseElementIndex.vue +git commit -m "Add BaseElementIndex shell with footer and aria-live region" +``` + +--- + +## Task 3: `DataTable.vue` (bare table core) + +**Files:** +- Create: `resources/js/modules/elements/components/DataTable.vue` + +**Interfaces:** +- Consumes: `useElementIndexSelection` (Task 1); `@tanstack/vue-table` `FlexRender`; `ColumnHeaderTitle`, `DropIndicator`, `LoadingSkeleton`, `useReorderableRows` (all already imported by today's `AdminTable`). +- Produces: props `{table, selectable?, readOnly?, loading?, reorderable?, layout?, spacing?, title?}`, emit `reorder: [startIndex, finishIndex]`. + +This task **moves** the entire `` render (thead/tbody, columns, reorder handles, skeleton, caption) out of today's `AdminTable.vue` (lines 279–479 template + supporting script) into `DataTable.vue`, replacing inline selection handlers with the composable. + +- [ ] **Step 1: Create the component by extracting AdminTable's table body** + +Create `resources/js/modules/elements/components/DataTable.vue`. Copy the following from the current `AdminTable.vue` **unchanged** except where noted: +- Script: imports for `Column`, `FlexRender`, `useId`, `useReorderableRows`, `TableSpacing`/`TableSpacingValue`, `ColumnHeaderTitle`, `DropIndicator`, `LoadingSkeleton`; the `resolveMetaClasses`, `getAriaSortAttribute`, `visibleColumnCount`, `tableStyles`, `skeletonCount`, `skeletonColumns`, `getClosestEdge`, `getRowPosition`, `titleString`, and reorder wiring (`setRowRef`, `setHandleRef`, `getDragState`, `getDropState`). +- Template: the `
` block (current lines 292–479) plus the `LoadingSkeleton` branch (285–291). + +Then make these **changes**: +1. Props become `{table, selectable?, readOnly?, loading?, reorderable?, layout?, spacing?, title?}` (drop the footer/pagination/actions/source/context/elementType props — those now live on `BaseElementIndex`). +2. Resolve `readOnly` the same way (`props.readOnly ?? usePage().props.readOnly`). +3. Replace the inline selection functions with the composable: + +```ts + import {useElementIndexSelection} from '@/modules/elements/composables/useElementIndexSelection'; + + const {onToggleAllSelected, selectRow} = useElementIndexSelection( + () => props.table, + { + selectable: () => props.selectable ?? false, + readOnly, + actions: () => [], // actions/bulk bar live on BaseElementIndex + }, + ); + + // Captures modifier state from the native click, because craft-checkbox's + // `model-value-changed` event does not carry `shiftKey`. + const pendingShiftKey = ref(false); + function rememberShift(event: MouseEvent) { + pendingShiftKey.value = event.shiftKey; + } +``` + +4. In the `` select-all checkbox, change the handler to: + +```html + +``` + +5. In each row's select checkbox, wire the click-then-change pair: + +```html + + + + + +``` + +6. Keep the ``'s existing `cp-table*` cell classes (`cp-table`, `cp-table-cell`, `cp-table-cell--select`, `cp-table--auto`, etc.) — those are the primitive and are **not** renamed. +7. Keep the scoped ` +``` + +- [ ] **Step 2: Typecheck + lint** + +Run: `npm run typecheck && npx eslint resources/js/modules/elements/components/ElementCards.vue` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add resources/js/modules/elements/components/ElementCards.vue +git commit -m "Slim ElementCards to a bare card grid body" +``` + +--- + +## Task 6: `content/Index.vue` composes `BaseElementIndex` + +**Files:** +- Modify: `resources/js/pages/content/Index.vue` (script lines ~179–207 and template lines ~240–261) + +**Interfaces:** +- Consumes: `BaseElementIndex`, `DataTable`, `ElementCards`. + +- [ ] **Step 1: Update imports** + +In `resources/js/pages/content/Index.vue`, replace: +```ts + import AdminTable from '@/modules/admin-table/components/AdminTable.vue'; + import ElementCards from '@/modules/elements/components/ElementCards.vue'; +``` +with: +```ts + import BaseElementIndex from '@/modules/elements/components/BaseElementIndex.vue'; + import DataTable from '@/modules/elements/components/DataTable.vue'; + import ElementCards from '@/modules/elements/components/ElementCards.vue'; +``` + +- [ ] **Step 2: Remove the `indexComponent`/`sharedProps`/`modeSpecificProps` juggling** + +Delete the `indexComponent`, `sharedProps`, and `modeSpecificProps` computeds (current lines ~179–207) and the now-unused `Component` import. Keep `TableSpacing` (used below). + +- [ ] **Step 3: Rewrite the template body** + +Replace the `` block (current lines ~240–261) with: + +```html + + + + +``` + +- [ ] **Step 4: Typecheck + lint** + +Run: `npm run typecheck && npx eslint resources/js/pages/content/Index.vue` +Expected: no errors. + +- [ ] **Step 5: Manual preview verification** + +Run the dev server (`npm run dev`) and open an element index (e.g. Entries). Confirm: table view renders with pagination footer; switching to cards view keeps the same footer/selection; selecting rows shows the bulk-actions bar; "X–Y of Z" is correct. This is the parity check the component tests would otherwise cover (no component-mount infra exists in `resources/js`). + +- [ ] **Step 6: Commit** + +```bash +git add resources/js/pages/content/Index.vue +git commit -m "Compose BaseElementIndex directly on the content index page" +``` + +--- + +## Task 7: Remove dead shell CSS from `base.css` + +**Files:** +- Modify: `packages/craftcms-cp/src/styles/shared/base.css` + +- [ ] **Step 1: Delete the shell rules** + +In `packages/craftcms-cp/src/styles/shared/base.css`, remove the now-unused shell rules: `.cp-table-header`, `.cp-table-footer`, and `.cp-table-body__header` (around lines 340–355). Leave every `.cp-table` / `.cp-table-cell` / `.cp-table--*` rule intact. + +- [ ] **Step 2: Confirm nothing else references the removed classes** + +Run: +```bash +grep -rn "cp-table-wrapper\|cp-table-header\|cp-table-footer\|cp-table-body\b\|cp-table-body__header" resources packages --include=*.vue --include=*.scss --include=*.css | grep -vE "dist/|build/" +``` +Expected: no matches. + +- [ ] **Step 3: Lint styles** + +Run: `npm run lint:styles` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add packages/craftcms-cp/src/styles/shared/base.css +git commit -m "Remove dead cp-table shell rules superseded by element-index" +``` + +--- + +## Task 8: Keyboard selection navigation in `DataTable` + +**Files:** +- Modify: `resources/js/modules/elements/components/DataTable.vue` + +**Interfaces:** +- Consumes: `toggleRow`, `extendSelectionTo`, `selectRow` from the selection composable (already destructured — add `toggleRow`, `extendSelectionTo`). + +- [ ] **Step 1: Add roving-focus + key handling to selectable rows** + +In `DataTable.vue`, add `toggleRow` and `extendSelectionTo` to the composable destructure. On the `` for data rows, when `selectable`, add `tabindex="0"` and a keydown handler: + +```html + +``` + +Add the handler in script: + +```ts + function focusRowByIndex(index: number, el: HTMLElement) { + const table = el.closest('table'); + const rows = table?.querySelectorAll('tbody > tr[tabindex]'); + rows?.[index]?.focus(); + } + + function onRowKeydown(row: any, index: number, event: KeyboardEvent) { + if (!props.selectable) return; + const rows = props.table.getRowModel().rows; + const target = event.currentTarget as HTMLElement; + switch (event.key) { + case ' ': + case 'Enter': + event.preventDefault(); + toggleRow(row); + break; + case 'ArrowDown': + event.preventDefault(); + if (event.shiftKey) extendSelectionTo(rows[Math.min(index + 1, rows.length - 1)]); + focusRowByIndex(Math.min(index + 1, rows.length - 1), target); + break; + case 'ArrowUp': + event.preventDefault(); + if (event.shiftKey) extendSelectionTo(rows[Math.max(index - 1, 0)]); + focusRowByIndex(Math.max(index - 1, 0), target); + break; + } + } +``` + +- [ ] **Step 2: Typecheck + lint** + +Run: `npm run typecheck && npx eslint resources/js/modules/elements/components/DataTable.vue` +Expected: no errors. + +- [ ] **Step 3: Manual preview verification** + +On an element index (table view): focus a row, press Space to toggle selection, Arrow up/down to move focus, Shift+Arrow to extend selection. Confirm the aria-live region announces "N items selected". + +- [ ] **Step 4: Commit** + +```bash +git add resources/js/modules/elements/components/DataTable.vue +git commit -m "Add keyboard selection navigation to DataTable rows" +``` + +--- + +## Task 9: Keyboard selection navigation in `ElementCards` + +**Files:** +- Modify: `resources/js/modules/elements/components/ElementCards.vue` + +- [ ] **Step 1: Add key handling to selectable cards** + +In `ElementCards.vue`, add `toggleRow` and `extendSelectionTo` to the composable destructure. On the `
  • ` add `tabindex` + keydown when selectable, resolving the row via `rowFor(element.id)`: + +```html +
  • +``` + +```ts + function focusCardByIndex(index: number, el: HTMLElement) { + const list = el.closest('ul.card-grid'); + const items = list?.querySelectorAll(':scope > li[tabindex]'); + items?.[index]?.focus(); + } + + function onCardKeydown(id: number | string, index: number, event: KeyboardEvent) { + if (!props.selectable) return; + const target = event.currentTarget as HTMLElement; + const last = props.data!.length - 1; + switch (event.key) { + case ' ': + case 'Enter': + event.preventDefault(); + toggleRow(rowFor(id)); + break; + case 'ArrowRight': + case 'ArrowDown': + event.preventDefault(); + if (event.shiftKey) extendSelectionTo(rowFor(props.data![Math.min(index + 1, last)].id)); + focusCardByIndex(Math.min(index + 1, last), target); + break; + case 'ArrowLeft': + case 'ArrowUp': + event.preventDefault(); + if (event.shiftKey) extendSelectionTo(rowFor(props.data![Math.max(index - 1, 0)].id)); + focusCardByIndex(Math.max(index - 1, 0), target); + break; + } + } +``` + +- [ ] **Step 2: Typecheck + lint** + +Run: `npm run typecheck && npx eslint resources/js/modules/elements/components/ElementCards.vue` +Expected: no errors. + +- [ ] **Step 3: Manual preview verification** + +On an element index (cards view): focus a card, Space toggles selection, Arrow keys move focus, Shift+Arrow extends. Confirm parity with the table view. + +- [ ] **Step 4: Commit** + +```bash +git add resources/js/modules/elements/components/ElementCards.vue +git commit -m "Add keyboard selection navigation to ElementCards" +``` + +--- + +## Final verification + +- [ ] `npx vitest run resources/js/modules/elements/composables/useElementIndexSelection.test.ts` — green. +- [ ] `npm run typecheck` — clean. +- [ ] `npm run lint` — clean (js + styles + typecheck). +- [ ] `grep -rn "cp-table-wrapper\|cp-table-header\|cp-table-footer\|cp-table-body__header" resources packages --include=*.vue --include=*.scss --include=*.css | grep -vE "dist/|build/"` — no matches. +- [ ] Manual preview: element index table + cards views (footer, selection, bulk actions, keyboard nav) and one standalone `AdminTable` page (e.g. Settings → Fields) render correctly. + +## Notes for the implementer + +- **Why the click-then-change pair for checkboxes:** `craft-checkbox` is a Lion web component; its `model-value-changed` event does not include `shiftKey`. The `@click` handler stashes the modifier into `pendingShiftKey` just before `model-value-changed` fires, so `selectRow` receives the correct shift state. Keep both handlers on the same element. +- **Do not** rename `.cp-table` / `.cp-table-cell` / `.cp-table--*` — legacy Twig (`editableTable.twig`, `PhpInfo.twig`) and Storybook depend on them. +- **No component-mount tests:** `resources/js` has no `@vue/test-utils`/jsdom setup. Component behavior is verified via typecheck + lint + manual preview. If the team later adds mount infra, `BaseElementIndex`/`DataTable`/`ElementCards` smoke tests are the natural follow-up. diff --git a/docs/superpowers/specs/2026-07-01-base-element-index-design.md b/docs/superpowers/specs/2026-07-01-base-element-index-design.md new file mode 100644 index 00000000000..1795dcfb999 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-base-element-index-design.md @@ -0,0 +1,195 @@ +# BaseElementIndex — shared index shell for table & card views + +**Date:** 2026-07-01 +**Branch:** `feature/inertia-element-indexes` +**Status:** Approved design, pending implementation plan + +## Problem + +`AdminTable.vue` and `ElementCards.vue` render the same element index two ways (a +table vs a card grid) and duplicate a large amount of code between them: + +- ~11 identical props (`table`, `selectable`, `loading`, `from/to/total`, + `enableAdjustPageSize`, `pageSizeOptions`, `actions`, `elementType`, `source`, + `context`) +- Selection state + handlers (select-all guard, per-item toggle, `selectedIds`, + bulk-action visibility) +- Footer/pagination proxies + `show*` flags +- The entire ~80-line footer template (bulk-actions bar + "X–Y of Z" + pager + + page-size select) — byte-for-byte identical +- The shell chrome (`cp-table-wrapper > cp-table-header + cp-table-body + + cp-table-footer`) and its CSS, split across `base.css` and both components' + scoped blocks + +The goal is to abstract the shared shell/footer/selection into a single +`BaseElementIndex` component (plus composables), rename the shared **shell** +classes to `element-index-*`, and fold in a few missing Craft 5 features at the +shell/selection layer. + +## Decisions (resolved during brainstorming) + +1. **Rename scope: shell classes only.** Rename + `cp-table-wrapper/header/body/footer` → `element-index__*`. Leave the generic + `.cp-table` / `.cp-table-cell` / `.cp-table--*` table primitive untouched — it + is a general `
  • ` style used by legacy Twig (`editableTable.twig`, + `PhpInfo.twig`) and Storybook, and the card view has no cells. +2. **Structure: page composes the base.** `content/Index.vue` renders + `` directly, with either a table or the card grid in its body + slot. +3. **AdminTable fate: split (bare core + base wraps it).** Extract the bare + `
    ` into a new `DataTable.vue`. `BaseElementIndex` owns shell + footer + + selection. A thin `AdminTable.vue` (= `BaseElementIndex` + `DataTable`) keeps + the ~19 standalone callers' public API unchanged. +4. **Craft 5 features to include now:** ARIA live region (shell), shift-click + range selection, keyboard selection navigation. **Deferred:** infinite scroll / + load-more (its own project); body-specific features (structure/hierarchy, + drag-sort beyond current reorder, inline editing, thumbnails, sticky proxy + scrollbar) are out of scope. + +## Architecture + +``` +content/Index.vue ──renders──► BaseElementIndex (shell + footer + aria-live) + ├─ #header slot ► ElementIndexToolbar + └─ #body slot ► DataTable (bare
    ) ← table mode + ElementCards (bare card grid) ← cards mode + +AdminTable.vue (thin wrapper, unchanged public API for the ~19 callers) + └─ renders BaseElementIndex + DataTable internally +``` + +### `BaseElementIndex.vue` (new — `resources/js/modules/elements/components/`) + +Owns everything shared between the two views: + +- **Shell chrome:** `.element-index` wrapper → `.element-index__header` (slot + `header`) + `.element-index__body` (slot `body`, with `aria-busy` while loading) + + `.element-index__footer`. +- **Footer:** the full footer template — bulk-actions bar + (`showBulkActions && hasSelection`), displayed-rows text (`from/to/total`), + pager, and page-size select. The footer pagination UI proxies (`pageIndexProxy`, + `pageSizeProxy`, `showPagination`, `showPageSize`, `showDisplayedRows`, + `showFooter`) live here directly — this is now their only consumer, so no + separate composable is needed for them. +- **ARIA live region:** a visually-hidden `role="status" aria-live="polite"` + element announcing what the shell owns — loading state, result totals on load, + and selection changes ("3 selected" / "Selection cleared"). +- Uses `useElementIndexSelection` for footer bulk-action visibility and the + selection announcements. + +Props (shared set): `table`, `selectable`, `readOnly`, `loading`, `from`, `to`, +`total`, `enableAdjustPageSize`, `pageSizeOptions`, `actions`, `elementType`, +`source`, `context`. +Slots: `header`, `body`. +Emits: `action-performed`. + +Placement in `modules/elements` matches the existing dependency direction — +`AdminTable` already imports `BulkActionsBar` from `modules/elements`. + +### `DataTable.vue` (new — the bare table core) + +Extracted from today's `AdminTable.vue`: just the `
    ` — thead/tbody, column +rendering, reorder handles, loading skeleton, spacing/compact variants, and the +`caption` (fed by `title`). Body-only; renders no shell or footer. Used both +inside the `AdminTable` wrapper and directly in `content/Index.vue`'s body slot. + +Wires its checkboxes via `useElementIndexSelection`. Owns its own focus / +`tabindex` / keydown handling for keyboard selection nav (table-row specific), +calling the composable's selection primitives. + +### `ElementCards.vue` (modified → bare) + +Keeps the card grid (`card-grid`, `card-grid-header`) and per-card checkbox; loses +the shell and footer (now provided by `BaseElementIndex`). It is only used on the +content index today, so there is no external fallout. Owns its own card-grid focus +/ keydown handling for keyboard selection nav. + +### `AdminTable.vue` (modified → thin wrapper) + +Renders `BaseElementIndex` + `DataTable`, splitting `$props` into a `baseProps` +subset (shared/footer/selection) and a `viewProps` subset (table-core: +`reorderable`, `layout`, `spacing`, `title`), forwarding `@reorder` and +`@action-performed`. `loading` goes to both (base for `aria-busy`/live region, +`DataTable` for the skeleton). The ~19 standalone callers keep their current API. + +### `useElementIndexSelection.ts` (new composable — `modules/elements/composables/`) + +`(table, {readOnly, actions})` → `selectedIds`, `hasSelection`, `hasBulkActions`, +`showBulkActions`, `bulkActionsActive`, `clearSelection`, `readOnly` (resolved via +the `usePage` fallback), plus the selection handlers used by both bodies: + +- `onToggleAllSelected(event)` — guarded select-all (existing behavior). +- `onRowSelectionClick(row, {shiftKey})` — **new shift-click range selection.** + With `shiftKey` and a stored anchor, selects the range between the anchor and the + clicked row in current row-model order (`table.getRowModel().rows`); otherwise + toggles the row and re-anchors. Owns the `anchorIndex` ref and the + `craft-checkbox` `model-value-changed` guard (only acts when the event value + differs from current state). +- `toggleRow(row)` / `extendSelectionTo(row)` — primitives for the bodies' + **keyboard nav** (arrow to move focus, space to toggle, shift+arrow to extend). + Focus, `tabindex`, and keydown wiring stay in the body components (table rows vs + cards differ structurally); the composable performs the selection math only. + +Called independently by `BaseElementIndex`, `DataTable`, and `ElementCards` — all +derive from the same shared `table` instance, so no prop threading is required. + +**Implementation note (for planning):** `craft-checkbox`'s `model-value-changed` +event does not carry `shiftKey`. Reading modifier state for range selection will +require a native `click`/`keydown` handler on the checkbox or row that captures +`event.shiftKey` and passes it explicitly to `onRowSelectionClick`. The composable +API takes an explicit `{shiftKey}` rather than digging it out of the event. + +## Shell class rename map (shell-only) + +| Old | New | +|---|---| +| `cp-table-wrapper` | `element-index` | +| `cp-table-header` | `element-index__header` | +| `cp-table-body` | `element-index__body` | +| `cp-table-body__header` | `element-index__body-header` | +| `cp-table-footer` | `element-index__footer` | +| `cp-table-footer--has-selection` | `element-index__footer--has-selection` | +| `cp-table-footer__lead` | `element-index__footer-lead` | + +Shell CSS consolidates into `BaseElementIndex.vue`'s scoped styles and is removed +from `packages/craftcms-cp/src/styles/shared/base.css` and from the old scoped +blocks in `AdminTable.vue` / `ElementCards.vue`. + +## Files + +**New** +- `resources/js/modules/elements/components/BaseElementIndex.vue` +- `resources/js/modules/elements/components/DataTable.vue` +- `resources/js/modules/elements/composables/useElementIndexSelection.ts` + +**Modified** +- `resources/js/modules/admin-table/components/AdminTable.vue` → thin wrapper +- `resources/js/modules/elements/components/ElementCards.vue` → bare card grid +- `resources/js/pages/content/Index.vue` → composes `BaseElementIndex`; `#table-header` → `#header` +- `packages/craftcms-cp/src/styles/shared/base.css` → remove shell rules + +**Untouched** +- Existing `useElementIndex*` composables (`Pagination` = TanStack model config, + `Sort`, `Filters`, `Columns`, `ViewMode`, `ViewState`, `Loading`) +- The `.cp-table` / `.cp-table-cell` / `.cp-table--*` primitive and its legacy Twig + consumers +- The ~19 standalone `AdminTable` callers (API preserved by the wrapper) + +## Testing + +- **Unit — `useElementIndexSelection`** (against a mock TanStack table): per-row + toggle, select-all guard (ignores programmatic `model-value-changed`), shift-click + range-selection math (anchor + range in row-model order), and the + `showBulkActions` / `bulkActionsActive` flags. +- **Component smoke:** `BaseElementIndex` (footer shows/hides, bulk bar appears on + selection, aria-live region present and updates), `DataTable`, `ElementCards`, + plus a couple of the standalone `AdminTable` callers to confirm no regression. +- Confirm the exact Vitest config path for `resources/js` during planning. + +## Out of scope / deferred + +- Infinite scroll / load-more footer mode (separate project). +- Body-specific Craft 5 features: structure/hierarchy view, drag-sort beyond the + current reorder, inline editing, thumbnails, sticky horizontal proxy scrollbar. +- Renaming the `.cp-table` cell primitive or the slot beyond `#table-header` → + `#header`. diff --git a/packages/craftcms-cp/scripts/generate-colors.js b/packages/craftcms-cp/scripts/generate-colors.js index efe8fcaf61d..006f9f1dd8b 100644 --- a/packages/craftcms-cp/scripts/generate-colors.js +++ b/packages/craftcms-cp/scripts/generate-colors.js @@ -31,6 +31,7 @@ const availableColors = [ 'white', 'gray', 'black', + 'slate', ]; const semanticColors = { diff --git a/packages/craftcms-cp/scripts/generate-vue-wrappers.js b/packages/craftcms-cp/scripts/generate-vue-wrappers.js index 5c24cb5a0c3..1b11f8363b4 100644 --- a/packages/craftcms-cp/scripts/generate-vue-wrappers.js +++ b/packages/craftcms-cp/scripts/generate-vue-wrappers.js @@ -241,6 +241,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) { @@ -284,7 +296,6 @@ function generateValueWrapper(component) {