diff --git a/docs/slideouts.md b/docs/slideouts.md new file mode 100644 index 00000000000..0c28340d494 --- /dev/null +++ b/docs/slideouts.md @@ -0,0 +1,437 @@ +# Slideouts + +Slideouts render a CP screen in a panel over the current page. A slideout request returns a normal +Inertia response, so **the same page component renders as either a full page or a slideout** — only +the shell around it differs. + +Screens that haven't been ported to a Vue page still work: they fall back to a built-in component +that draws the server-rendered HTML the response already carries. + +> This is the Vue/Inertia system. The legacy `Craft.CpScreenSlideout` (jQuery/Garnish, in +> `resources/js/modules/slideout/`) still exists and is unchanged — see +> [Coexisting with the legacy stack](#coexisting-with-the-legacy-stack). + +## Basic Usage + +Open any CP URL in a slideout: + +```vue + +``` + +`useSlideoutOpener()` returns `{open, closeAll}` and can be called outside `setup()`, so plain event +handlers and non-Vue code can use it too. + +The same functions are on `window.Craft`, which is handy from the console: + +```js +Craft.openSlideout('/admin/content/entries/news/5-hello'); +Craft.closeSlideout(id); +Craft.closeAllSlideouts(); +``` + +> These globals are registered when the Inertia CP boots, so they only exist on Inertia pages. On a +> page still served by the legacy stack (the dashboard, for example) `Craft.openSlideout` is +> undefined. + +## Opening + +### `openSlideout(href, options?)` + +Fetches `href` as an Inertia page and mounts it in a panel. Returns the `SlideoutInstance`. + +| Option | Type | Description | +| --- | --- | --- | +| `opener` | `HTMLElement \| null` | Element to refocus when the panel closes, and what the [stacking rules](#stacking-and-nesting) are resolved against. Defaults to whatever had focus when it opened. | +| `width` | `string` | Width for this panel, as any CSS **length**. Defaults to `--slideout-width`. | +| `onSaved` | `(result) => void` | Called when the screen saves. See [Telling the opener](#telling-the-opener). | + +Pass `opener` whenever you have it — focus restoration and nesting both depend on it. + +### Other exports + +```ts +import { + openSlideout, + closeSlideout, // (id: string) => void + closeAllSlideouts, + useSlideout, + useSlideoutOpener, +} from '@/common/slideouts'; +``` + +## Inside a slideout + +`useSlideout()` returns the panel the calling component is in, or `null` on a full page: + +```vue + +``` + +| Member | Description | +| --- | --- | +| `instance` | The `SlideoutInstance` (`id`, `href`, `props`, `loading`, `error`, …) | +| `close()` | Closes this panel, and anything nested inside it | +| `reload()` | Re-fetches the screen | +| `saved(result?)` | Reports a save to the opener; `false` means nobody was listening | + +To branch on context without caring about the panel itself: + +```ts +import {useIsSlideout} from '@/common/composables/screen'; + +if (useIsSlideout()) { + // … +} +``` + +## Making a screen work in a slideout + +**Any `CpScreenResponse` already works.** No controller changes are needed. + +```php +return (new CpScreenResponse()) + ->title($entryType->name) + ->contentTemplate('…') + ->action('entry-types/save'); +``` + +Requesting that screen with the slideout headers returns an Inertia page. If the response has no +`inertiaPage()`, the component is `cp/Screen`, which renders the response's HTML fragments +(`content`, `details`, `tabs`, `contentNotice`, `errorSummary`, `toolbar`) into the shell's slots. + +Porting a screen to a real Vue page is then just: + +```php +->inertiaPage('settings/entry-types/Edit', $viewModel) +``` + +That same component now serves the full page and the slideout. + +### How a request is routed + +`CpScreenResponse::toResponse()` treats a JSON-accepting request as a slideout — the convention +Craft 5 established — and picks the wire format from `X-Inertia`: + +| Caller | `Accept` | `X-Inertia` | Result | +| --- | --- | --- | --- | +| Inertia page visit | `text/html` | yes | Full page | +| Legacy `CpScreenSlideout` | `application/json` | no | Flat HTML payload (unchanged) | +| Vue slideout | `application/json` | yes | Inertia page object | + +Inertia's own client sends `Accept: text/html`, so an ordinary page visit can never be mistaken for +a slideout. + +Both formats are built from one pass over the screen, under a per-request input namespace keyed to +the `X-Craft-Container-Id` header — so two slideouts of the same screen don't collide on input +names. + +### Detecting a slideout server-side + +Unchanged from Craft 5 — the request accepts JSON: + +```php +if ($this->request->hasHeader('X-Craft-Container-Id')) { + // Rendering into a slideout. +} +``` + +## Saving + +Saving works in a slideout without navigating, on both kinds of screen. No controller changes are +needed: `RespondsWithFlash` already answers JSON whenever the request accepts it, so `asSuccess()` +and `asFailure()` do the right thing on their own. + +> Failures come back as **400**, not Laravel's usual 422 — `asJsonFailure()` picks it. Anything +> reading the status directly needs to expect that. + +### Vue pages + +Nothing to do. `useSettingsSave` detects the slideout and swaps its own submit strategy: + +| | Full page | Slideout | +| --- | --- | --- | +| Transport | navigating `form.submit()` | direct `axios` post | +| `redirect` in payload | sent | omitted — a panel closes instead | +| On success | follows the redirect | closes the panel, reloads the page behind | +| On failure | Inertia error bag | `form.setError()` from the 400 body | + +The elevated-session (423) retry, `transform`, and `elevatedFields` behave identically in both. + +Cmd/Ctrl+S is "save and continue editing" — it saves and **keeps the +panel open**. The Save button closes it. Internally that's the `redirect: false` flag, which is why +`SlideoutScreen` deliberately doesn't pass it. + +### Server-rendered screens + +Screens without an `inertiaPage()` have no Vue form — just markup — so the shell submits its own +`