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
437 changes: 437 additions & 0 deletions docs/slideouts.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions resources/js/bootstrap/cp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import {inertiaPageRegistry, resolveInertiaPage} from './inertia-pages.js';
import AppLayout from '@/common/layouts/AppLayout.vue';
import {createCpComponentRegistry} from './components.js';
import {registerSlideoutGlobals} from '@/common/slideouts';
import {configureIcons} from './icons.js';
import LocalFsSettings from '@/components/Filesystems/LocalFsSettings.vue';

Expand Down Expand Up @@ -55,7 +56,7 @@
return '';
}

return value.toString().replace(/^\/+|\/+$/g, '');

Check warning on line 59 in resources/js/bootstrap/cp.ts

View workflow job for this annotation

GitHub Actions / Code Quality / Oxlint

typescript(no-base-to-string)

'value' will use Object's default stringification format ('[object Object]') when stringified.
}

// Create our object
Expand Down Expand Up @@ -161,6 +162,7 @@

handleNonInertiaRequests();
ensureLegacyNotificationContainer();
registerSlideoutGlobals();

console.log('Calling booted callbacks', bootedCallbacks);
bootedCallbacks.forEach((callback) => callback(this));
Expand Down
7 changes: 7 additions & 0 deletions resources/js/common/components/HtmlFragmentRenderer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
}
);

const emit = defineEmits<{
/** The fragment — assets included — is in the document. */
(e: 'ready', element: HTMLElement): void;
}>();

const container = ref<HTMLElement | null>(null);
const disposers: AppendHtmlDisposer[] = [];
let lastKey = '';
Expand Down Expand Up @@ -105,6 +110,8 @@
if (html) {
(window as any).Craft?.initUiElements?.(element);
}

emit('ready', element);
},
{immediate: true}
);
Expand Down
40 changes: 28 additions & 12 deletions resources/js/common/components/LayoutSlot.vue
Original file line number Diff line number Diff line change
@@ -1,29 +1,45 @@
<script setup lang="ts">
/**
* Renders its children into the ambient `AppLayout`'s matching
* Renders its children into the enclosing screen shell's matching
* `LayoutSlotOutlet`. Content stays compiled in the page's scope, so
* all page bindings (props, form state, refs, …) remain reactive even
* though the DOM is teleported into the layout.
* though the DOM is teleported into the shell.
*/
import {onBeforeUnmount, onMounted} from 'vue';
import {
registerLayoutSlot,
unregisterLayoutSlot,
} from '@/common/composables/layoutSlots';
import {computed, onBeforeUnmount, onMounted} from 'vue';
import {useLayoutSlotRegistry} from '@/common/composables/layoutSlots';

const props = defineProps<{name: string}>();
const props = defineProps<{
name: string;
/**
* Target a specific shell's outlets. Only needed to reach past the
* nearest shell — the registry resolves the right one by default.
*/
scope?: string;
}>();

const registry = useLayoutSlotRegistry();
const scope = computed(() => props.scope ?? registry.scope);

// The outlet selector is scoped to one shell: a slideout keeps the base
// page mounted, so an unscoped `[data-layout-slot=…]` would match the base
// page's outlet first and teleport the slideout's content into the page
// behind it.
const target = computed(
() =>
`[data-layout-scope='${scope.value}'][data-layout-slot='${props.name}']`
);

// Register after mount, not during setup: registration mutates shared
// reactive state, and doing that mid-render forces the parent layout to
// reactive state, and doing that mid-render forces the parent shell to
// re-render while this subtree is still mounting, which races the
// deferred Teleport. Outlet targets are always in the DOM (hidden while
// unfilled), so the teleport doesn't depend on registration timing.
onMounted(() => registerLayoutSlot(props.name));
onBeforeUnmount(() => unregisterLayoutSlot(props.name));
onMounted(() => registry.register(props.name));
onBeforeUnmount(() => registry.unregister(props.name));
</script>

<template>
<Teleport defer :to="`[data-layout-slot='${name}']`">
<Teleport defer :to="target">
<slot></slot>
</Teleport>
</template>
6 changes: 4 additions & 2 deletions resources/js/common/components/LayoutSlotOutlet.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
<script setup lang="ts">
import {computed} from 'vue';
import {hasLayoutSlot} from '@/common/composables/layoutSlots';
import {useLayoutSlotRegistry} from '@/common/composables/layoutSlots';

const props = defineProps<{name: string}>();

const filled = computed(() => hasLayoutSlot(props.name));
const registry = useLayoutSlotRegistry();
const filled = computed(() => registry.has(props.name));
</script>

<template>
<div
:data-layout-scope="registry.scope"
:data-layout-slot="name"
:style="{display: filled ? 'contents' : 'none'}"
></div>
Expand Down
25 changes: 16 additions & 9 deletions resources/js/common/components/SlideoutButton.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
<script setup lang="ts">
/**
* A button that opens a CP screen in a slideout and reports back when it
* saves.
*
* Callers listen for `success` and refresh whatever the screen affects —
* registering `onSaved` also opts the panel out of its default
* reload-the-whole-page-behind behavior, which is the point: the caller knows
* which props actually changed.
*/
import {useTemplateRef} from 'vue';
import {ButtonVariant} from '@craftcms/ui';
import {useSlideoutOpener} from '@/common/slideouts';

defineProps<{
url: string;
Expand All @@ -10,17 +20,14 @@
(e: 'success'): void;
}>();

const invoker = useTemplateRef('invoker');
const invoker = useTemplateRef<HTMLElement>('invoker');
const {open} = useSlideoutOpener();

function openSlideout(url: string) {
const slideout = new Craft.CpScreenSlideout(url);

slideout.on('submit', () => {
emit('success');
});

slideout.on('close', () => {
invoker.value?.focus();
// Focus goes back to this button on close, courtesy of `opener`.
void open(url, {
opener: invoker.value,
onSaved: () => emit('success'),
});
}
</script>
Expand Down
Loading
Loading