Fix results sticky toolbar [SCI-9344]

This commit is contained in:
Ivan Kljun 2023-09-25 13:58:13 +02:00
parent 3a22d7715d
commit 1373994559
5 changed files with 195 additions and 77 deletions

View file

@ -0,0 +1,43 @@
/*
* Mixin to observe and reflect DOM changes of a module's name in '.my_module-name .view-mode'.
* - Initializes a MutationObserver to watch for modifications.
* - Updates the `moduleName` data property on detected changes.
*/
export default {
data() {
return {
observer: null,
moduleName: '',
};
},
mounted() {
this.observeChanges();
},
beforeDestroy() {
if (this.observer) {
this.observer.disconnect();
}
},
methods: {
observeChanges() {
const targetNode = document.querySelector('.my_module-name .view-mode');
if (!targetNode) return;
this.moduleName = targetNode.textContent;
const config = { characterData: true, childList: true, subtree: true };
const callback = (mutationsList) => {
mutationsList.forEach((mutation) => {
if (mutation.type === 'childList' || mutation.type === 'characterData') {
this.moduleName = targetNode.textContent;
}
});
};
this.observer = new MutationObserver(callback);
this.observer.observe(targetNode, config);
},
},
};

View file

@ -0,0 +1,103 @@
/*
* Mixin for adjusting stackable headers on scroll.
* - Tracks scroll position to modify headers' styles & positions.
* - Observes changes in the secondary navigation's height.
* - Adjusts TinyMCE editor header offset if present.
*/
export default {
data() {
return {
lastScrollTop: 0,
headerSticked: false,
secondaryNavigation: null,
taskSecondaryMenuHeight: 0,
};
},
computed: {
headerRef() {
return this.getHeader();
},
},
mounted() {
this.secondaryNavigation = document.querySelector('#taskSecondaryMenu');
this.resizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
this.taskSecondaryMenuHeight = entry.target.offsetHeight;
});
});
this.resizeObserver.observe(this.secondaryNavigation);
},
beforeDestroy() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
},
methods: {
initStackableHeaders() {
const header = this.headerRef;
const headerHeight = header.offsetHeight;
const headerTop = header.getBoundingClientRect().top;
const secondaryNavigationTop = this.secondaryNavigation.getBoundingClientRect().top;
// TinyMCE offset calculation
let stickyNavigationHeight = this.taskSecondaryMenuHeight;
if ($('.tox-editor-header').length > 0 && $('.tox-editor-header')[0].getBoundingClientRect().top > headerTop) {
stickyNavigationHeight += headerHeight;
}
// Add shadow to secondary navigation when it starts fly
if (this.secondaryNavigation.getBoundingClientRect().top === 0 && !this.headerSticked) {
this.secondaryNavigation.style.boxShadow = '0 9px 8px -2px rgba(0, 0, 0, 0.1)';
this.secondaryNavigation.style.zIndex = 252;
} else {
this.secondaryNavigation.style.boxShadow = 'none';
if (secondaryNavigationTop > 10) this.secondaryNavigation.style.zIndex = 11;
}
if (headerTop - 5 < this.taskSecondaryMenuHeight) { // When secondary navigation touch header
this.secondaryNavigation.style.top = `${headerTop - headerHeight}px`; // Secondary navigation starts slowly disappear
header.style.boxShadow = '0 9px 8px -2px rgba(0, 0, 0, 0.1)'; // Flying shadow
header.style.zIndex = 250;
this.headerSticked = true;
if (this.lastScrollTop > window.scrollY) { // When user scroll up
let newSecondaryTop = secondaryNavigationTop - (window.scrollY - this.lastScrollTop); // Calculate new top position of secondary navigation
if (newSecondaryTop > 0) newSecondaryTop = 0;
this.secondaryNavigation.style.top = `${newSecondaryTop}px`; // Secondary navigation starts slowly appear
this.secondaryNavigation.style.zIndex = 252;
header.style.top = `${this.taskSecondaryMenuHeight + newSecondaryTop - 1}px`; // Header starts getting offset to compensate secondary navigation position
// -1 to compensate small gap between header and secondary navigation
} else { // When user scroll down
let newSecondaryTop = secondaryNavigationTop - (window.scrollY - this.lastScrollTop); // Calculate new top position of secondary navigation
if (newSecondaryTop * -1 > this.taskSecondaryMenuHeight) newSecondaryTop = this.taskSecondaryMenuHeight * -1;
this.secondaryNavigation.style.top = `${newSecondaryTop}px`; // Secondary navigation starts slowly disappear
header.style.top = `${newSecondaryTop + this.taskSecondaryMenuHeight - 1}px`; // Header starts getting offset to compensate secondary navigation position
// -1 to compensate small gap between header and secondary navigation
if (newSecondaryTop * -1 >= this.taskSecondaryMenuHeight) this.secondaryNavigation.style.zIndex = 11;
}
} else {
// Just reset secondary navigation and header styles to initial state
this.secondaryNavigation.style.top = '0px';
header.style.top = '0px';
header.style.boxShadow = 'none';
header.style.zIndex = 10;
this.headerSticked = false;
}
// Apply TinyMCE offset
$('.tox-editor-header').css(
'top',
stickyNavigationHeight + parseInt($(this.secondaryNavigation).css('top'), 10)
);
this.lastScrollTop = window.scrollY; // Save last scroll position to when user scroll up/down
},
},
};

View file

@ -2,10 +2,10 @@
<div v-if="protocol.id" class="task-protocol">
<div ref="header" class="task-section-header ml-[-1rem] w-[calc(100%_+_2rem)] px-4 bg-sn-white sticky top-0 transition" v-if="!inRepository">
<div class="portocol-header-left-part truncate grow">
<template v-if="headerSticked && protocol.attributes.assignable_my_module_name">
<template v-if="headerSticked && moduleName">
<i class="sn-icon sn-icon-navigator sci--layout--navigator-open cursor-pointer p-1.5 border rounded border-sn-light-grey mr-4"></i>
<div @click="scrollTop" class="task-section-title w-[calc(100%_-_4rem)] cursor-pointer">
<h2 class="truncate">{{ protocol.attributes.assignable_my_module_name }}</h2>
<h2 class="truncate leading-6">{{ moduleName }}</h2>
</div>
</template>
<template v-else >
@ -204,6 +204,8 @@
import PublishProtocol from './modals/publish_protocol.vue'
import UtilsMixin from '../mixins/utils.js'
import stackableHeadersMixin from '../mixins/stackableHeadersMixin';
import moduleNameObserver from '../mixins/moduleNameObserver';
export default {
name: 'ProtocolContainer',
@ -214,7 +216,7 @@
}
},
components: { Step, InlineEdit, ProtocolOptions, Tinymce, ReorderableItemsModal, ProtocolMetadata, PublishProtocol},
mixins: [UtilsMixin],
mixins: [UtilsMixin, stackableHeadersMixin, moduleNameObserver],
computed: {
inRepository() {
return this.protocol.attributes.in_repository
@ -235,17 +237,16 @@
reordering: false,
publishing: false,
stepToReload: null,
headerSticked: false,
lastScrollTop: 0,
}
},
created() {
mounted() {
$.get(this.protocolUrl, (result) => {
this.protocol = result.data;
this.$nextTick(() => {
this.refreshProtocolStatus();
if (!this.inRepository) {
window.addEventListener('scroll', this.initStackableHeaders, false);
this.initStackableHeaders();
}
});
$.get(this.urls.steps_url, (result) => {
@ -259,6 +260,9 @@
}
},
methods: {
getHeader() {
return this.$refs.header;
},
reloadStep(step) {
this.stepToReload = step;
},
@ -405,74 +409,12 @@
$.post(this.urls.publish_url, {version_comment: comment, view: 'show'})
},
scrollTop() {
console.log("clicekd")
window.scrollTo(0, 0);
setTimeout(() => {
$('.my_module-name .view-mode').trigger('click');
$('.my_module-name .input-field').focus();
}, 300)
},
initStackableHeaders() {
let protocolHeader = this.$refs.header;
let secondaryNavigation = document.querySelector('#taskSecondaryMenu');
let protocolHeaderHeight = protocolHeader.offsetHeight;
let protocolHeaderTop = protocolHeader.getBoundingClientRect().top;
let secondaryNavigationHeight = secondaryNavigation.offsetHeight;
let secondaryNavigationTop = secondaryNavigation.getBoundingClientRect().top;
// TinyMCE offset calculation
let stickyNavigationHeight = secondaryNavigationHeight;
if ($('.tox-editor-header').length > 0 && $('.tox-editor-header')[0].getBoundingClientRect().top > protocolHeaderTop) {
stickyNavigationHeight += protocolHeaderHeight;
}
// Add shadow to secondary navigation when it starts fly
if (secondaryNavigation.getBoundingClientRect().top == 0 && !this.headerSticked) {
secondaryNavigation.style.boxShadow = '0px 5px 8px 0px rgba(0, 0, 0, 0.10)';
secondaryNavigation.style.zIndex= 251;
} else {
secondaryNavigation.style.boxShadow = 'none';
if (secondaryNavigationTop > 10) secondaryNavigation.style.zIndex= 0;
}
if (protocolHeaderTop - 5 < protocolHeaderHeight) { // When secondary navigation touch protocol header
secondaryNavigation.style.top = protocolHeaderTop - protocolHeaderHeight + 'px'; // Secondary navigation starts slowly disappear
protocolHeader.style.boxShadow = '0px 5px 8px 0px rgba(0, 0, 0, 0.10)'; // Flying shadow
protocolHeader.style.zIndex= 250;
this.headerSticked = true;
if (this.lastScrollTop > window.scrollY) { // When user scroll up
let newSecondaryTop = secondaryNavigationTop - (window.scrollY - this.lastScrollTop); // Calculate new top position of secondary navigation
if (newSecondaryTop > 0) newSecondaryTop = 0;
secondaryNavigation.style.top = newSecondaryTop + 'px'; // Secondary navigation starts slowly appear
secondaryNavigation.style.zIndex= 251;
protocolHeader.style.top = secondaryNavigationHeight + newSecondaryTop - 1 + 'px'; // Protocol header starts getting offset to compensate secondary navigation position
// -1 to compensate small gap between protocol header and secondary navigation
} else { // When user scroll down
let newSecondaryTop = secondaryNavigationTop - (window.scrollY - this.lastScrollTop); // Calculate new top position of secondary navigation
if (newSecondaryTop * -1 > secondaryNavigationHeight ) newSecondaryTop = secondaryNavigationHeight * -1;
secondaryNavigation.style.top = newSecondaryTop + 'px'; // Secondary navigation starts slowly disappear
protocolHeader.style.top = newSecondaryTop + secondaryNavigationHeight - 1 + 'px'; // Protocol header starts getting offset to compensate secondary navigation position
// -1 to compensate small gap between protocol header and secondary navigation
if (newSecondaryTop * -1 >= secondaryNavigationHeight) secondaryNavigation.style.zIndex= 0;
}
} else {
// Just reset secondary navigation and protocol header styles to initial state
secondaryNavigation.style.top = '0px';
protocolHeader.style.boxShadow = 'none';
protocolHeader.style.zIndex= 0;
this.headerSticked = false;
}
// Apply TinyMCE offset
$('.tox-editor-header').css('top',
stickyNavigationHeight + parseInt($(secondaryNavigation).css('top'), 10)
);
this.lastScrollTop = window.scrollY; // Save last scroll position to when user scroll up/down
}
}
}

View file

@ -1,10 +1,14 @@
<template>
<div class="results-wrapper">
<ResultsToolbar :sort="sort"
<ResultsToolbar
ref="resultsToolbar"
:sort="sort"
:canCreate="canCreate == 'true'"
:archived="archived == 'true'"
:active_url="active_url"
:archived_url="archived_url"
:headerSticked="headerSticked"
:moduleName="moduleName"
@setSort="setSort"
@setFilters="setFilters"
@newResult="createResult"
@ -34,9 +38,13 @@
import ResultsToolbar from './results_toolbar.vue';
import Result from './result.vue';
import stackableHeadersMixin from '../mixins/stackableHeadersMixin';
import moduleNameObserver from '../mixins/moduleNameObserver';
export default {
name: 'Results',
components: { ResultsToolbar, Result },
mixins: [stackableHeadersMixin, moduleNameObserver],
props: {
url: { type: String, required: true },
canCreate: { type: String, required: true },
@ -51,18 +59,24 @@
filters: {},
resultToReload: null,
nextPageUrl: null,
loadingPage: false
loadingPage: false,
}
},
mounted() {
window.addEventListener('scroll', this.loadResults, false);
window.addEventListener('scroll', this.initStackableHeaders, false);
this.nextPageUrl = this.url;
this.loadResults();
this.initStackableHeaders();
},
beforeDestroy() {
window.removeEventListener('scroll', this.loadResults, false);
window.removeEventListener('scroll', this.initStackableHeaders, false);
},
methods: {
getHeader() {
return this.$refs.resultsToolbar.$refs.resultsHeaderToolbar;
},
reloadResult(result) {
this.resultToReload = result;
},

View file

@ -1,15 +1,22 @@
<template>
<div class="result-toolbar p-3 flex justify-between rounded-md bg-sn-white">
<div class="result-toolbar__left">
<button v-if="canCreate" :title="i18n.t('my_modules.results.add_title')" class="btn btn-secondary" @click="$emit('newResult')">
<div ref="resultsHeaderToolbar" class="result-toolbar sticky top-0 transition p-3 flex justify-between bg-sn-white">
<div v-if="headerSticked" class="flex items-center truncate grow">
<i class="sn-icon sn-icon-navigator sci--layout--navigator-open cursor-pointer p-1.5 border rounded border-sn-light-grey mr-4"></i>
<div @click="scrollTop" class="w-[calc(100%_-_4rem)] cursor-pointer">
<h2 class="truncate leading-6 mt-2.5 mb-2.5">{{ moduleName }}</h2>
</div>
</div>
<div class="result-toolbar__left flex items-center">
<button v-if="canCreate" :title="i18n.t('my_modules.results.add_title')" class="btn btn-secondary" :class="{'mr-3': headerSticked}" @click="$emit('newResult')">
<i class="sn-icon sn-icon-new-task"></i>
{{ i18n.t('my_modules.results.add_label') }}
</button>
</div>
<div class="dropdown view-switch" >
<div class="btn btn-secondary view-switch-button prevent-shrink" id="viewSwitchButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
<div class="dropdown view-switch flex items-center">
<div class="btn btn-secondary view-switch-button prevent-shrink" :class="{'mr-3': headerSticked}" id="viewSwitchButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
<span v-if="archived" class="state-view-switch-btn-name">{{ i18n.t('my_modules.results.archived_results') }}</span>
<span v-else class="state-view-switch-btn-name">{{ i18n.t('my_modules.results.active_results') }}</span>
<span class="sn-icon sn-icon-down"></span>
@ -66,8 +73,10 @@
sort: { type: String, required: true },
canCreate: { type: Boolean, required: true },
archived: { type: Boolean, required: true },
headerSticked: { type: Boolean, required: true },
active_url: { type: String, required: true },
archived_url: { type: String, required: true }
archived_url: { type: String, required: true },
moduleName: { type: String, required: true }
},
data() {
return {
@ -120,6 +129,13 @@
},
expandResults() {
$('.result-wrapper .collapse').collapse('show')
},
scrollTop() {
window.scrollTo(0, 0);
setTimeout(() => {
$('.my_module-name .view-mode').trigger('click');
$('.my_module-name .input-field').focus();
}, 300)
}
}
}