overhaul modals from ground up

This commit is contained in:
joshuaboud 2022-06-28 18:12:18 -03:00
parent 75df808dad
commit 97e733d58e
No known key found for this signature in database
GPG Key ID: 17EFB59E2A8BF50E
3 changed files with 396 additions and 157 deletions

View File

@ -0,0 +1,113 @@
<template>
<ModalPopup
:show="show ?? show_"
:fullWidth="opts_.fullWidth ?? fullWidth"
@close="if (opts_.clickAwayCancels ?? clickAwayCancels) respond(false);"
@after-leave="reset"
>
<template #header>
<slot name="header">
{{ headerText_ }}
</slot>
</template>
<slot>
{{ bodyText_ }}
</slot>
<template #footer>
<slot name="footer">
<button
type="button"
class="btn btn-secondary"
@click="respond(false)"
>
{{ opts_.cancelText ?? cancelText }}
</button>
<button
type="button"
:class="['btn', (opts_.confirmDangerous ?? confirmDangerous) ? 'btn-danger' : 'btn-primary']"
:disabled="disabled"
@click="respond(true)"
>
{{ opts_.confirmText ?? confirmText }}
</button>
</slot>
</template>
</ModalPopup>
</template>
<script>
import { ref } from 'vue';
import ModalPopup from './ModalPopup.vue';
export default {
props: {
show: {
type: Boolean,
required: false,
default: null,
},
clickAwayCancels: Boolean,
confirmDangerous: Boolean,
cancelText: {
type: String,
required: false,
default: 'Cancel',
},
confirmText: {
type: String,
required: false,
default: 'OK',
},
fullWidth: Boolean,
disabled: Boolean,
},
setup(props, { emit }) {
const show_ = ref(false);
const headerText_ = ref("");
const bodyText_ = ref("");
const opts_ = ref({});
const onReponse_ = ref(null);
const ask = (headerText, bodyText, opts = {}) => {
return new Promise(resolve => {
opts_.value = opts;
headerText_.value = headerText;
bodyText_.value = bodyText;
onReponse_.value = resolve;
show_.value = true;
});
};
const respond = (response) => {
onReponse_.value?.(response);
show_.value = false;
if (response)
emit('confirm');
else
emit('cancel');
}
const reset = () => {
headerText_.value = "";
bodyText_.value = "";
opts_.value = {};
onReponse_.value = null;
emit('after-leave');
}
return {
show_,
headerText_,
bodyText_,
opts_,
ask,
respond,
reset,
}
},
components: {
ModalPopup,
},
emits: [
'cancel',
'confirm',
'after-leave',
]
}
</script>

View File

@ -16,114 +16,118 @@ If not, see <https://www.gnu.org/licenses/>.
--> -->
<template> <template>
<TransitionRoot <transition
as="div" enter-active-class="ease-out duration-500"
class="fixed inset-0 z-10 overflow-visible" enter-from-class="opacity-0"
:show="showModal" enter-to-class="opacity-100"
leave-active-class="ease-in duration-500"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
> >
<TransitionChild
as="template"
enter="ease-out duration-500"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-500"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed z-10 inset-0 bg-neutral-500/75 dark:bg-black/50 transition-opacity pointer" />
</TransitionChild>
<div <div
v-if="() => show ?? show_"
class="fixed z-10 inset-0 bg-neutral-500/75 dark:bg-black/50 transition-opacity pointer"
/>
</transition>
<transition
enter-active-class="ease-out duration-300"
enter-from-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-90"
enter-to-class="opacity-100 translate-y-0 sm:scale-100"
leave-active-class="ease-in duration-100"
leave-from-class="opacity-100 translate-y-0 sm:scale-100"
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-75"
@after-leave="reset"
>
<div
v-if="() => show ?? show_"
class="fixed z-10 inset-0 overflow-hidden flex items-end sm:items-center justify-center px-4 pb-20 sm:p-0" class="fixed z-10 inset-0 overflow-hidden flex items-end sm:items-center justify-center px-4 pb-20 sm:p-0"
@click.self="$emit('close')" @click.self="close(false)"
> >
<TransitionChild <div
as="template" :class="[fullWidth ? 'sm:max-w-full' : 'sm:max-w-lg', 'inline-flex flex-col items-stretch overflow-hidden transform transition-all text-left z-10']">
enter="ease-out duration-300" <div class="block w-[512px]" /> <!-- set min width of div -->
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-90" <div class="card flex flex-col items-stretch overflow-hidden">
enter-to="opacity-100 translate-y-0 sm:scale-100" <div class="card-header">
leave="ease-in duration-100" <h3 class="text-header">
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-75"
>
<div
:class="[autoWidth ? 'sm:max-w-full' : 'sm:max-w-lg', 'inline-flex flex-col items-stretch overflow-hidden transform transition-all text-left z-10']">
<div class="block w-[512px]" /> <!-- set min width of div -->
<div class="card flex flex-col items-stretch overflow-hidden">
<div class="card-header">
<slot name="header"> <slot name="header">
<h3 {{ headerText_ }}
class="text-header" </slot>
v-html="headerText" </h3>
/> </div>
<div class="card-body flex flex-row items-center gap-2">
<slot name="icon" />
<div class="grow overflow-x-auto">
<slot>
{{ bodyText_ }}
</slot> </slot>
</div> </div>
<div class="card-body flex flex-row items-center gap-2"> </div>
<slot name="icon" /> <div class="card-footer w-full">
<div class="grow overflow-x-auto"> <div class="button-group-row justify-end overflow-x-auto">
<slot />
</div>
</div>
<div class="card-footer button-group-row justify-end">
<slot name="footer"> <slot name="footer">
<button <button
v-if="!noCancel"
type="button" type="button"
class="btn btn-secondary" class="btn btn-primary"
@click="$emit('cancel'); $emit('close')" @click="close(true)"
> >
{{ cancelText }} OK
</button>
<button
type="button"
:class="['btn', applyDangerous ? 'btn-danger' : 'btn-primary']"
:disabled="disableContinue"
@click="$emit('apply'); $emit('close')"
>
{{ applyText }}
</button> </button>
</slot> </slot>
</div> </div>
</div> </div>
</div> </div>
</TransitionChild> </div>
</div> </div>
</TransitionRoot> </transition>
</template> </template>
<script> <script>
import { TransitionChild, TransitionRoot } from '@headlessui/vue'; import { ref } from 'vue';
export default { export default {
props: { props: {
showModal: Boolean, show: {
noCancel: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: null,
}, },
autoWidth: Boolean, fullWidth: Boolean,
headerText: String,
cancelText: {
type: String,
required: false,
default: "Cancel",
},
applyText: {
type: String,
required: false,
default: "Apply",
},
applyDangerous: Boolean,
disableContinue: Boolean,
}, },
components: { setup(props, { emit }) {
TransitionChild, const show_ = ref(false);
TransitionRoot, const headerText_ = ref("");
const bodyText_ = ref("");
const onClose_ = ref(null);
const open = (headerText, bodyText) => {
return new Promise(resolve => {
headerText_.value = headerText;
bodyText_.value = bodyText;
onClose_.value = resolve;
show_.value = true;
});
};
const close = (clickedOk) => {
onClose_.value?.(clickedOk);
show_.value = false;
emit('close');
};
const reset = () => {
headerText_.value = '';
bodyText_.value = '';
onClose_.value = null;
emit('after-leave');
}
return {
show_,
headerText_,
bodyText_,
open,
close,
reset,
}
}, },
emits: [ emits: [
'apply',
'cancel',
'close', 'close',
'after-leave',
] ]
}; };
</script> </script>

View File

@ -1,27 +1,55 @@
<template> <template>
<ModalPopup <ModalConfirm
:showModal="show" :show="show ?? show_"
:headerText="headerText" :clickAwayCancels="clickAwayCancels"
autoWidth :confirmDangerous="opts_.confirmDangerous ?? confirmDangerous"
:disableContinue="!valid" :cancelText="cancelText"
@cancel="cancel()" :confirmText="confirmText"
@apply="apply()" :fullWidth="fullWidth"
:disabled="!valid"
@cancel="cancel"
@confirm="confirm"
@after-leave="reset"
> >
<template #header>
<slot name="header">
{{ headerText_ }}
</slot>
</template>
<div v-if="inputs_.length === 0">
<input
type="text"
v-model="defaultInputValue_"
class="w-full input-textlike"
/>
</div>
<div <div
v-for="input in inputs" v-else
v-for="input in inputs_"
:key="input.key" :key="input.key"
> >
<label <label
v-if="input.label" v-if="input.label"
class="block text-label" class="block text-label"
>{{ input.label }}</label> >{{ input.label }}</label>
<template v-if="input.type === 'radio'">
<input
v-for="option in input.options"
v-bind="{ ...input.props }"
type="radio"
:value="option"
v-model="input.value"
ref="inputRefs_"
/>
</template>
<input <input
:class="[!['button', 'checkbox', 'color', 'file', 'radio', 'range'].includes(input.type) ? 'input-textlike w-full' : input.type == 'checkbox' ? 'input-checkbox' : '']" v-else
v-bind="{...input.props}" v-bind="{ ...input.props }"
:type="input.type" :type="input.type"
:value="output[input.key] ?? input.default"
:placeholder="input.placeholder" :placeholder="input.placeholder"
@input="(event) => { output[input.key] = event.target.value; validate(); }" v-model="input.value"
@change="input.feedback = input.validate(input.value)"
ref="inputRefs_"
/> />
<div <div
v-if="input.feedback" v-if="input.feedback"
@ -31,100 +59,194 @@
<span class="text-feedback text-error">{{ input.feedback }}</span> <span class="text-feedback text-error">{{ input.feedback }}</span>
</div> </div>
</div> </div>
</ModalPopup> </ModalConfirm>
</template> </template>
<script> <script>
import { ref, computed, nextTick } from 'vue'; import { ref, nextTick } from 'vue';
import { ExclamationCircleIcon } from '@heroicons/vue/solid'; import { ExclamationCircleIcon } from '@heroicons/vue/solid';
import ModalPopup from './ModalPopup.vue'; import ModalConfirm from './ModalConfirm.vue';
/**
* @template T
* @typedef {Object} ModalPromptInputObj
* @property {String} key - Key to index output object
* @property {String} label - Text to go in label
* @property {String} type - input tag type property value
* @property {T} value - current value of input to be v-modelled
* @property {String} placeholder - Placeholder text
* @property {ModalPromptInputValidationCallback<T>} validate - Validation function, return string if invalid or null if valid
* @property {String} feedback - Feedback if invalid
* @property {Object} props - Extra properties to v-bind to the input element
* @property {*[]|undefined} options - Array of option values for radio button input
*/
/**
* @template T
* @callback ModalPromptInputValidationCallback
* @param {T} value - Current value of input to validate
* @returns {String|null} - Return null if valid or string explaining why it's invalid
*/
export default { export default {
props: {
show: {
type: Boolean,
required: false,
default: null,
},
clickAwayCancels: Boolean,
confirmDangerous: Boolean,
cancelText: {
type: String,
required: false,
default: 'Cancel',
},
confirmText: {
type: String,
required: false,
default: 'OK',
},
fullWidth: Boolean,
},
setup(props, { emit }) { setup(props, { emit }) {
const show = ref(false); const show_ = ref(false);
const headerText = ref(""); const headerText_ = ref("");
const inputs = ref([]); const opts_ = ref({});
const output = ref({}); const inputs_ = ref([]);
const valid = ref(false); const valid_ = ref(false);
const apply = ref(() => null); const resolver_ = ref(null);
const cancel = ref(() => null); const defaultInputRef_ = ref();
const defaultInputValue_ = ref("");
const inputRefs_ = ref([]);
/** const ask = (headerText, inputs = [], opts = {}) => {
* Promise with method to add input to prompt const prom = new Promise(resolve => {
* @typedef {Object} ModalPromptPromise opts_.value = opts;
* @property {function} addInput headerText_.value = headerText;
*/ inputs_.value = inputs;
resolver_.value = resolve;
/** show_.value = true;
* Prompt user for input nextTick(() => {
* @param {string} header - Text to display in popup header if (inputs.length) {
* @returns {ModalPromptPromise} - Resolves object with responses or null if cancelled inputRefs_.value[0].focus();
*/ } else {
const prompt = (header) => { defaultInputRef_.value.focus();
headerText.value = header; }
output.value = {}; });
inputs.value = []; });
valid.value = false;
show.value = true;
const prom = new Promise((resolve, reject) => {
apply.value = () => resolve(output.value);
cancel.value = () => resolve(null);
}).finally(() => show.value = false);
/** /**
* Callback to validate input, cannot be arrow function * Add a text input
* @callback ModalPromptInputValidationCallback * @param {String} key - Key to index output object
* @param {any} - value of input * @param {String} label - Text to go in label
* @returns {boolean} - true if valid, false otherwise * @param {String} defaultValue - Default value
* @param {String} placeholder - Placeholder text
* @param {ModalPromptInputValidationCallback<String>} validate - Input validation callback
* @param {Object} props - Extra properties to v-bind to the input element
*/ */
/** prom.addTextInput = (key, label, defaultValue, placeholder, validate = () => null, props = {}) => {
* Add an input to the prompt /**
* @param {string} key - object key for result * @type {ModalPromptInputObj<String>}
* @param {string} type - input tag type prop value */
* @param {string} label - label for input
* @param {string} placeholder - input placeholder if applicable
* @param {any} defaultValue - Default value of input
* @param {ModalPromptInputValidationCallback} validate - Validation callback for input
* @param {object} props - optional extra properties for input tag
* @returns {ModalPromptPromise} - Resolves object with responses or null if cancelled
*/
const addInput = (key, type, label, placeholder, defaultValue, validate, props) => {
const input = { const input = {
key, key,
type,
label, label,
type: 'text',
value: defaultValue,
placeholder, placeholder,
default: defaultValue, validate,
props, props: {...props, class: 'input-textlike ' + (props.class ?? '')},
feedback: '', feedback: '',
} }
output.value[key] = defaultValue; inputs_.value.push(input);
input.validate = validate?.bind(input), return prom;
inputs.value.push(input); }
/**
* Add a checkbox input
* @param {String} key - Key to index output object
* @param {String} label - Text to go in label
* @param {*} defaultValue - Default value
* @param {*} trueValue - Value of result when checked
* @param {*} falseValue - Value of result when unchecked
* @param {Object} props - Extra properties to v-bind to the input element
*/
prom.addCheckboxInput = (key, label, defaultValue = false, trueValue = true, falseValue = false, props = {}) => {
/**
* @type {ModalPromptInputObj<*>}
*/
const input = {
key,
label,
type: 'checkbox',
value: defaultValue,
placeholder,
validate: () => null,
props: { ...props, 'true-value': trueValue, 'false-value': falseValue, class: 'input-checkbox ' + (props.class ?? '') },
feedback: '',
}
inputs_.value.push(input);
return prom; return prom;
} }
prom.addInput = addInput;
return prom; return prom;
} };
const confirm = () => {
const response = inputs_.value.length
? inputs_.value.reduce((response, input) => {
response[input.key] = input.value;
return response;
}, {})
: defaultInputValue_.value;
resolver_.value?.(response);
show_.value = false;
emit('confirm');
};
const cancel = () => {
resolver_.value?.(null);
show_.value = false;
emit('cancel');
};
const reset = () => {
headerText_ = "";
opts_.value = {};
inputs_.value = [];
valid_.value = false;
resolver_.value = null;
defaultInputValue_.value = "";
emit('after-leave');
};
const validate = () => { const validate = () => {
valid.value = inputs.value.every(input => input.validate?.(output.value[input.key]) ?? true); valid_.value = inputs_.value.every(input => input.feedback === null);
} };
return { return {
show, show_,
headerText, opts_,
inputs, headerText_,
output, inputs_,
valid, valid_,
apply, resolver_,
defaultInputRef_,
defaultInputValue_,
inputRefs_,
ask,
confirm,
cancel, cancel,
prompt, reset,
validate, validate,
} }
}, },
components: { components: {
ModalPopup, ModalConfirm,
ExclamationCircleIcon, ExclamationCircleIcon,
} },
emits: [
'confirm',
'cancel',
'after-leave',
]
} }
</script> </script>