This is possible using JSLink/CSR client-side validation. That is, if you are on a version of SharePoint that still has JSLink/CSR available.
From my understanding, SP Online modern experience does not use the JSLink/CSR system from older versions. I am unsure if SP Online classic experience still has that (can someone who knows confirm in a comment?). SP 2013 / 2016 on-prem definitely has JSLink/CSR available to tap into (and I believe 2019 as well? Again, not sure on that one...)
Here's some example code (tested on 2013):
var MyNamespace = MyNamespace || {};
MyNamespace.PreviousFieldValue = null;
MyNamespace.MyFieldValidator = function myFieldValidator () {
MyNamespace.MyFieldValidator.prototype.Validate = function (fieldValue) {
var isError = false;
var errorMessage = '';
// only validate if the field has a value
if (fieldValue) {
// assume that we do need to check if it is a past date
var shouldCheckIfPastDate = true;
var enteredDate = new Date(fieldValue);
// if there was a previous value, check to see if it has changed
if (MyNamespace.PreviousFieldValue !== null) {
var previousDate = new Date(MyNamespace.PreviousFieldValue);
// if the date has not changed, don't check if it's a past date
// Date object can't do equality comparison natively, so we have to use getTime()
if (enteredDate.getTime() === previousDate.getTime()) {
shouldCheckIfPastDate = false;
}
}
if (shouldCheckIfPastDate) {
// get todays date and zero out the time so we are only comparing the date
var today = new Date();
today.setHours(0, 0, 0, 0);
// Date object can handle greater than/less than comparison natively
if (enteredDate < today) {
isError = true;
errorMessage = 'You cannot enter a past date.';
}
}
}
return new SPClientForms.ClientValidation.ValidationResult(isError, errorMessage);
}
}
MyNamespace.MyFieldFieldOverride = {
edit: function edit (ctx) {
// if it's not blank, store the current value of the field to check against later
if (ctx.CurrentFieldValue) {
MyNamespace.PreviousFieldValue = ctx.CurrentFieldValue;
}
// get the form context and create a new validator set
var formCtx = SPClientTemplates.Utility.GetFormContextForCurrentField(ctx);
var fieldValidators = new SPClientForms.ClientValidation.ValidatorSet();
// if the field has been marked as required, register the default required field validator
if (formCtx.fieldSchema.Required) {
fieldValidators.RegisterValidator(new SPClientForms.ClientValidation.RequiredValidator());
}
// register our custom validator
fieldValidators.RegisterValidator(new MyNamespace.MyFieldValidator());
// add the error callback and register the validator set with the form context
formCtx.registerValidationErrorCallback(formCtx.fieldName, MyNamespace.MyFieldFieldOverride.onError);
formCtx.registerClientValidator(formCtx.fieldName, fieldValidators);
// render the default form control for a date column
var returnHtml = SPFieldDateTime_Edit(ctx);
// add a spot for our custom error message
returnHtml += "<span id='MyFieldCustomError' class='ms-formvalidation ms-csrformvalidation'></span>";
return returnHtml;
},
onError: function onError (error) {
document.getElementById('MyFieldCustomError').innerHTML = "<span role='alert'>" + error.errorMessage + "</span>";
},
render: function render () {
SPClientTemplates.TemplateManager.RegisterTemplateOverrides({
Templates: {
Fields: {
YourDateField: {
'NewForm': MyNamespace.MyFieldFieldOverride.edit,
'EditForm': MyNamespace.MyFieldFieldOverride.edit
}
}
}
});
}
}
// handle MDS-enabled or MDS-disabled situations
RegisterModuleInit(SPClientRenderer.ReplaceUrlTokens('~site/path/to/MyFieldValidator.js'), MyNamespace.MyFieldFieldOverride.render);
MyNamespace.MyFieldFieldOverride.render();