Skip to content

Commit 4acfa7e

Browse files
[6.x] Make stacks smoother (#14210)
Co-authored-by: Jason Varga <jason@pixelfear.com>
1 parent 27ef681 commit 4acfa7e

4 files changed

Lines changed: 113 additions & 47 deletions

File tree

resources/css/components/stacks.css

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,8 @@
2323
}
2424
.stack-container {
2525
@apply absolute inset-0;
26-
transition: left 200ms ease-out;
27-
28-
[dir='rtl'] & {
29-
transition: right 200ms ease-out;
30-
}
26+
transition: transform 200ms ease-out;
27+
-webkit-backface-visibility: hidden;
3128
}
3229

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

45-
.stack-overlay-fade-enter-active,
46-
.stack-overlay-fade-leave-active {
47-
transition: opacity 200ms ease-out;
48-
will-change: opacity;
49-
}
50-
51-
.stack-overlay-fade-enter-from,
52-
.stack-overlay-fade-leave-to {
53-
opacity: 0;
54-
}
55-
5642
.stack-slide-enter-active,
5743
.stack-slide-leave-active {
5844
transition: transform 200ms ease-out, opacity 200ms ease-out;
@@ -81,10 +67,14 @@
8167
@media all and (max-width: 980px) {
8268
.stacks-on-stacks .stack-container {
8369
left: 0 !important;
70+
right: 0 !important;
71+
width: 100% !important;
72+
transform: translateX(0) !important;
8473

8574
[dir='rtl'] & {
86-
left: unset !important;
75+
left: 0 !important;
8776
right: 0 !important;
77+
transform: translateX(0) !important;
8878
}
8979
}
9080
}

resources/js/components/blueprints/Fields.vue

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,26 +34,25 @@
3434
<ui-button icon="add-circle" :text="__('Create Field')" @click="createField" />
3535
</div>
3636

37+
<!-- Single stack for picker → settings so selecting a field type swaps content without closing/reopening (no animation). -->
3738
<Stack
38-
v-model:open="isSelectingNewFieldtype"
39-
@closed="isSelectingNewFieldtype = false"
40-
:title="__('Fieldtypes')"
41-
icon="cog"
39+
:open="isCreateFieldStackOpen"
40+
@update:open="(value) => { if (!value) closeCreateFieldStack() }"
41+
@closed="closeCreateFieldStack"
42+
:title="isSelectingNewFieldtype ? __('Fieldtypes') : undefined"
43+
:icon="isSelectingNewFieldtype ? 'cog' : null"
44+
:inset="!!pendingCreatedField"
45+
:show-close-button="isSelectingNewFieldtype"
46+
:wrap-slot="!pendingCreatedField"
4247
v-slot="{ close }"
4348
>
44-
<fieldtype-selector @closed="close" @selected="fieldtypeSelected" />
45-
</Stack>
46-
47-
<Stack
48-
:open="pendingCreatedField != null"
49-
@update:open="(value) => { if (!value) pendingCreatedField = null }"
50-
@closed="pendingCreatedField = null"
51-
v-slot="{ close }"
52-
inset
53-
:show-close-button="false"
54-
:wrap-slot="false"
55-
>
49+
<fieldtype-selector
50+
v-if="isSelectingNewFieldtype"
51+
@closed="onPickerClosed"
52+
@selected="fieldtypeSelected"
53+
/>
5654
<field-settings
55+
v-else-if="pendingCreatedField"
5756
ref="settings"
5857
:type="pendingCreatedField.config.type"
5958
:root="true"
@@ -87,7 +86,7 @@ export default {
8786
LinkFields,
8887
FieldtypeSelector,
8988
FieldSettings,
90-
Stack,
89+
Stack,
9190
},
9291
9392
props: {
@@ -111,6 +110,12 @@ export default {
111110
};
112111
},
113112
113+
computed: {
114+
isCreateFieldStackOpen() {
115+
return this.isSelectingNewFieldtype || this.pendingCreatedField != null;
116+
},
117+
},
118+
114119
mounted() {
115120
if (this.withCommandPalette) {
116121
this.addToCommandPalette();
@@ -143,6 +148,20 @@ export default {
143148
this.isSelectingNewFieldtype = true;
144149
},
145150
151+
closeCreateFieldStack() {
152+
this.isSelectingNewFieldtype = false;
153+
this.pendingCreatedField = null;
154+
},
155+
156+
// FieldtypeSelector emits 'closed' after 'selected'; only close stack when user cancelled (no selection).
157+
onPickerClosed() {
158+
if (this.pendingCreatedField == null) {
159+
this.closeCreateFieldStack();
160+
} else {
161+
this.isSelectingNewFieldtype = false;
162+
}
163+
},
164+
146165
fieldCreated(created) {
147166
let handle = created.handle;
148167
delete created.handle;
Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,61 @@
11
<template>
2-
<div class="portal-targets" :class="{ 'stacks-on-stacks': hasStacks, 'solo-narrow-stack': isSoloNarrowStack }">
2+
<div class="portal-targets" :class="{ 'stacks-on-stacks': hasStacks, 'stack-entering': isStackEntering, 'solo-narrow-stack': isSoloNarrowStack }">
33
<div v-for="(portal, i) in portals" :key="portal.id" :id="`portal-target-${portal.id}`" />
44
</div>
55
</template>
66

77
<script>
8+
import { events } from '@/api';
9+
810
export default {
11+
data() {
12+
return {
13+
isStackEntering: false,
14+
stackEnteringTimeout: null,
15+
};
16+
},
17+
918
computed: {
1019
portals() {
1120
return this.$portals.all();
1221
},
1322
23+
stackCount() {
24+
return this.$stacks.count();
25+
},
26+
1427
hasStacks() {
15-
return this.$stacks.count() > 0;
28+
return this.stackCount > 0;
1629
},
1730
1831
isSoloNarrowStack() {
1932
const stacks = this.$stacks.stacks();
2033
return stacks.length === 1 && stacks[0]?.data?.vm?.size === 'narrow';
2134
},
2235
},
36+
37+
watch: {
38+
stackCount(newCount, oldCount) {
39+
if (newCount <= oldCount) {
40+
return;
41+
}
42+
43+
clearTimeout(this.stackEnteringTimeout);
44+
this.isStackEntering = true;
45+
events.$emit('stacks.entering', true);
46+
47+
// Match the stack enter transition so CSS can ignore hover effects while a new stack slides in.
48+
this.stackEnteringTimeout = setTimeout(() => {
49+
this.isStackEntering = false;
50+
this.stackEnteringTimeout = null;
51+
events.$emit('stacks.entering', false);
52+
}, 200);
53+
},
54+
},
55+
56+
beforeUnmount() {
57+
clearTimeout(this.stackEnteringTimeout);
58+
events.$emit('stacks.entering', false);
59+
},
2360
};
2461
</script>

resources/js/components/ui/Stack/Stack.vue

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const stack = ref(null);
4545
const mounted = ref(false);
4646
const visible = ref(false);
4747
const isHovering = ref(false);
48+
const isStackEntering = ref(false);
4849
const escBinding = ref(null);
4950
const windowInnerWidth = ref(window.innerWidth);
5051
@@ -91,6 +92,18 @@ const leftOffset = computed(() => {
9192
const hasChild = computed(() => stacks.count() > depth.value);
9293
const direction = computed(() => config.get('direction', 'ltr'));
9394
95+
const containerStyle = computed(() => {
96+
if (props.size === 'full') {
97+
return direction.value === 'ltr' ? { left: 0, transform: 'translateZ(0)' } : { right: 0, transform: 'translateZ(0)' };
98+
}
99+
const x = leftOffset.value;
100+
const width = `calc(100% - ${x}px)`;
101+
if (direction.value === 'ltr') {
102+
return { left: 0, width, transform: `translateX(${x}px) translateZ(0)` };
103+
}
104+
return { right: 0, width, transform: `translateX(-${x}px) translateZ(0)` };
105+
});
106+
94107
const clickedHitArea = () => {
95108
if (!visible.value) return;
96109
if (!runCloseCallback()) return;
@@ -111,14 +124,21 @@ const mouseOutHitArea = () => {
111124
};
112125
113126
const windowResized = () => windowInnerWidth.value = window.innerWidth;
127+
const stackEnteringChanged = (entering) => {
128+
isStackEntering.value = entering;
129+
130+
if (entering) {
131+
isHovering.value = false;
132+
}
133+
};
114134
115135
function open() {
116136
if (!stack.value) stack.value = stacks.add(instance.proxy);
117137
118138
events.$on(`stacks.${depth.value}.hit-area-mouseenter`, () => (isHovering.value = true));
119139
events.$on(`stacks.${depth.value}.hit-area-mouseout`, () => (isHovering.value = false));
120140
121-
escBinding.value = keys.bindGlobal('esc', close);
141+
escBinding.value = keys.bindGlobal('esc', runCloseCallback);
122142
123143
window.addEventListener('resize', windowResized);
124144
@@ -180,10 +200,13 @@ watch(
180200
);
181201
182202
onMounted(() => {
203+
events.$on('stacks.entering', stackEnteringChanged);
204+
183205
if (props.open) open();
184206
});
185207
186208
onBeforeUnmount(() => {
209+
events.$off('stacks.entering', stackEnteringChanged);
187210
cleanup();
188211
});
189212
@@ -202,21 +225,18 @@ provide('closeStack', close);
202225
</Primitive>
203226
<teleport :to="portal" :order="depth" v-if="mounted">
204227
<div class="vue-portal-target stack">
228+
<div
229+
class="stack-overlay fixed inset-0 bg-gray-800/20 dark:bg-gray-800/50 transition-opacity duration-200 ease-out"
230+
:class="visible ? 'opacity-100' : 'opacity-0'"
231+
/>
232+
205233
<FocusScope
206234
:trapped="isTopPortal"
207235
loop
208236
class="stack-container outline-none"
209237
:class="{ 'stack-is-current': isTopStack }"
210-
:style="direction === 'ltr' ? { left: `${leftOffset}px` } : { right: `${leftOffset}px` }"
238+
:style="containerStyle"
211239
>
212-
<transition name="stack-overlay-fade">
213-
<div
214-
v-if="visible"
215-
class="stack-overlay fixed inset-0 bg-gray-800/20 dark:bg-gray-800/50"
216-
:style="direction === 'ltr' ? { left: `-${leftOffset}px` } : { right: `-${leftOffset}px` }"
217-
/>
218-
</transition>
219-
220240
<div
221241
class="stack-hit-area"
222242
:style="direction === 'ltr' ? { left: `-${offset}px` } : { right: `-${offset}px` }"
@@ -231,7 +251,7 @@ provide('closeStack', close);
231251
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"
232252
:class="[
233253
size === 'full' ? 'inset-2 w-[calc(100svw-1rem)]' : 'inset-y-2',
234-
{ '-translate-x-4 rtl:translate-x-4': isHovering }
254+
{ '-translate-x-4 rtl:translate-x-4': isHovering && !isStackEntering }
235255
]"
236256
>
237257
<template v-if="shouldAddHeader">

0 commit comments

Comments
 (0)