Skip to content
24 changes: 7 additions & 17 deletions resources/css/components/stacks.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,8 @@
}
.stack-container {
@apply absolute inset-0;
transition: left 200ms ease-out;

[dir='rtl'] & {
transition: right 200ms ease-out;
}
transition: transform 200ms ease-out;
-webkit-backface-visibility: hidden;
}

.stack-overlay {
Expand All @@ -42,17 +39,6 @@
@apply relative h-[calc(100svh-1rem)];
}

.stack-overlay-fade-enter-active,
.stack-overlay-fade-leave-active {
transition: opacity 200ms ease-out;
will-change: opacity;
}

.stack-overlay-fade-enter-from,
.stack-overlay-fade-leave-to {
opacity: 0;
}

.stack-slide-enter-active,
.stack-slide-leave-active {
transition: transform 200ms ease-out, opacity 200ms ease-out;
Expand Down Expand Up @@ -81,10 +67,14 @@
@media all and (max-width: 980px) {
.stacks-on-stacks .stack-container {
left: 0 !important;
right: 0 !important;
width: 100% !important;
transform: translateX(0) !important;

[dir='rtl'] & {
left: unset !important;
left: 0 !important;
right: 0 !important;
transform: translateX(0) !important;
}
}
}
53 changes: 36 additions & 17 deletions resources/js/components/blueprints/Fields.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,26 +34,25 @@
<ui-button icon="add-circle" :text="__('Create Field')" @click="createField" />
</div>

<!-- Single stack for picker → settings so selecting a field type swaps content without closing/reopening (no animation). -->
<Stack
v-model:open="isSelectingNewFieldtype"
@closed="isSelectingNewFieldtype = false"
:title="__('Fieldtypes')"
icon="cog"
:open="isCreateFieldStackOpen"
@update:open="(value) => { if (!value) closeCreateFieldStack() }"
@closed="closeCreateFieldStack"
:title="isSelectingNewFieldtype ? __('Fieldtypes') : undefined"
:icon="isSelectingNewFieldtype ? 'cog' : null"
:inset="!!pendingCreatedField"
:show-close-button="isSelectingNewFieldtype"
:wrap-slot="!pendingCreatedField"
v-slot="{ close }"
>
<fieldtype-selector @closed="close" @selected="fieldtypeSelected" />
</Stack>

<Stack
:open="pendingCreatedField != null"
@update:open="(value) => { if (!value) pendingCreatedField = null }"
@closed="pendingCreatedField = null"
v-slot="{ close }"
inset
:show-close-button="false"
:wrap-slot="false"
>
<fieldtype-selector
v-if="isSelectingNewFieldtype"
@closed="onPickerClosed"
@selected="fieldtypeSelected"
/>
<field-settings
v-else-if="pendingCreatedField"
ref="settings"
:type="pendingCreatedField.config.type"
:root="true"
Expand Down Expand Up @@ -87,7 +86,7 @@ export default {
LinkFields,
FieldtypeSelector,
FieldSettings,
Stack,
Stack,
},

props: {
Expand All @@ -111,6 +110,12 @@ export default {
};
},

computed: {
isCreateFieldStackOpen() {
return this.isSelectingNewFieldtype || this.pendingCreatedField != null;
},
},

mounted() {
if (this.withCommandPalette) {
this.addToCommandPalette();
Expand Down Expand Up @@ -143,6 +148,20 @@ export default {
this.isSelectingNewFieldtype = true;
},

closeCreateFieldStack() {
this.isSelectingNewFieldtype = false;
this.pendingCreatedField = null;
},

// FieldtypeSelector emits 'closed' after 'selected'; only close stack when user cancelled (no selection).
onPickerClosed() {
if (this.pendingCreatedField == null) {
this.closeCreateFieldStack();
} else {
this.isSelectingNewFieldtype = false;
}
},

fieldCreated(created) {
let handle = created.handle;
delete created.handle;
Expand Down
41 changes: 39 additions & 2 deletions resources/js/components/portals/PortalTargets.vue
Original file line number Diff line number Diff line change
@@ -1,24 +1,61 @@
<template>
<div class="portal-targets" :class="{ 'stacks-on-stacks': hasStacks, 'solo-narrow-stack': isSoloNarrowStack }">
<div class="portal-targets" :class="{ 'stacks-on-stacks': hasStacks, 'stack-entering': isStackEntering, 'solo-narrow-stack': isSoloNarrowStack }">
<div v-for="(portal, i) in portals" :key="portal.id" :id="`portal-target-${portal.id}`" />
</div>
</template>

<script>
import { events } from '@/api';

export default {
data() {
return {
isStackEntering: false,
stackEnteringTimeout: null,
};
},

computed: {
portals() {
return this.$portals.all();
},

stackCount() {
return this.$stacks.count();
},

hasStacks() {
return this.$stacks.count() > 0;
return this.stackCount > 0;
},

isSoloNarrowStack() {
const stacks = this.$stacks.stacks();
return stacks.length === 1 && stacks[0]?.data?.vm?.size === 'narrow';
},
},

watch: {
stackCount(newCount, oldCount) {
if (newCount <= oldCount) {
return;
}

clearTimeout(this.stackEnteringTimeout);
this.isStackEntering = true;
events.$emit('stacks.entering', true);

// Match the stack enter transition so CSS can ignore hover effects while a new stack slides in.
this.stackEnteringTimeout = setTimeout(() => {
this.isStackEntering = false;
this.stackEnteringTimeout = null;
events.$emit('stacks.entering', false);
}, 200);
},
},

beforeUnmount() {
clearTimeout(this.stackEnteringTimeout);
events.$emit('stacks.entering', false);
},
};
</script>
42 changes: 31 additions & 11 deletions resources/js/components/ui/Stack/Stack.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const stack = ref(null);
const mounted = ref(false);
const visible = ref(false);
const isHovering = ref(false);
const isStackEntering = ref(false);
const escBinding = ref(null);
const windowInnerWidth = ref(window.innerWidth);

Expand Down Expand Up @@ -91,6 +92,18 @@ const leftOffset = computed(() => {
const hasChild = computed(() => stacks.count() > depth.value);
const direction = computed(() => config.get('direction', 'ltr'));

const containerStyle = computed(() => {
if (props.size === 'full') {
return direction.value === 'ltr' ? { left: 0, transform: 'translateZ(0)' } : { right: 0, transform: 'translateZ(0)' };
}
const x = leftOffset.value;
const width = `calc(100% - ${x}px)`;
if (direction.value === 'ltr') {
return { left: 0, width, transform: `translateX(${x}px) translateZ(0)` };
}
return { right: 0, width, transform: `translateX(-${x}px) translateZ(0)` };
});

const clickedHitArea = () => {
if (!visible.value) return;
if (!runCloseCallback()) return;
Expand All @@ -111,14 +124,21 @@ const mouseOutHitArea = () => {
};

const windowResized = () => windowInnerWidth.value = window.innerWidth;
const stackEnteringChanged = (entering) => {
isStackEntering.value = entering;

if (entering) {
isHovering.value = false;
}
};

function open() {
if (!stack.value) stack.value = stacks.add(instance.proxy);

events.$on(`stacks.${depth.value}.hit-area-mouseenter`, () => (isHovering.value = true));
events.$on(`stacks.${depth.value}.hit-area-mouseout`, () => (isHovering.value = false));

escBinding.value = keys.bindGlobal('esc', close);
escBinding.value = keys.bindGlobal('esc', runCloseCallback);

window.addEventListener('resize', windowResized);

Expand Down Expand Up @@ -180,10 +200,13 @@ watch(
);

onMounted(() => {
events.$on('stacks.entering', stackEnteringChanged);

if (props.open) open();
});

onBeforeUnmount(() => {
events.$off('stacks.entering', stackEnteringChanged);
cleanup();
});

Expand All @@ -202,21 +225,18 @@ provide('closeStack', close);
</Primitive>
<teleport :to="portal" :order="depth" v-if="mounted">
<div class="vue-portal-target stack">
<div
class="stack-overlay fixed inset-0 bg-gray-800/20 dark:bg-gray-800/50 transition-opacity duration-200 ease-out"
:class="visible ? 'opacity-100' : 'opacity-0'"
/>

<FocusScope
:trapped="isTopPortal"
loop
class="stack-container outline-none"
:class="{ 'stack-is-current': isTopStack }"
:style="direction === 'ltr' ? { left: `${leftOffset}px` } : { right: `${leftOffset}px` }"
:style="containerStyle"
>
<transition name="stack-overlay-fade">
<div
v-if="visible"
class="stack-overlay fixed inset-0 bg-gray-800/20 dark:bg-gray-800/50"
:style="direction === 'ltr' ? { left: `-${leftOffset}px` } : { right: `-${leftOffset}px` }"
/>
</transition>

<div
class="stack-hit-area"
:style="direction === 'ltr' ? { left: `-${offset}px` } : { right: `-${offset}px` }"
Expand All @@ -231,7 +251,7 @@ provide('closeStack', close);
class="stack-content fixed flex flex-col sm:end-1.5 bg-content-bg dark:bg-dark-content-bg overflow-hidden rounded-xl shadow-[0_8px_5px_-6px_rgba(0,0,0,0.1),_0_3px_8px_0_rgba(0,0,0,0.02),_0_30px_22px_-22px_rgba(39,39,42,0.15)] dark:shadow-[0_5px_20px_rgba(0,0,0,.5)] transition-transform duration-200 ease-out will-change-transform"
:class="[
size === 'full' ? 'inset-2 w-[calc(100svw-1rem)]' : 'inset-y-2',
{ '-translate-x-4 rtl:translate-x-4': isHovering }
{ '-translate-x-4 rtl:translate-x-4': isHovering && !isStackEntering }
]"
>
<template v-if="shouldAddHeader">
Expand Down
Loading