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>
<TransitionRoot
as="div"
class="fixed inset-0 z-10 overflow-visible"
:show="showModal"
<transition
enter-active-class="ease-out duration-500"
enter-from-class="opacity-0"
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
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"
@click.self="$emit('close')"
@click.self="close(false)"
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-90"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-100"
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">
<div
: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']">
<div class="block w-[512px]" /> <!-- set min width of div -->
<div class="card flex flex-col items-stretch overflow-hidden">
<div class="card-header">
<h3 class="text-header">
<slot name="header">
<h3
class="text-header"
v-html="headerText"
/>
{{ headerText_ }}
</slot>
</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>
</div>
<div class="card-body flex flex-row items-center gap-2">
<slot name="icon" />
<div class="grow overflow-x-auto">
<slot />
</div>
</div>
<div class="card-footer button-group-row justify-end">
</div>
<div class="card-footer w-full">
<div class="button-group-row justify-end overflow-x-auto">
<slot name="footer">
<button
v-if="!noCancel"
type="button"
class="btn btn-secondary"
@click="$emit('cancel'); $emit('close')"
class="btn btn-primary"
@click="close(true)"
>
{{ cancelText }}
</button>
<button
type="button"
:class="['btn', applyDangerous ? 'btn-danger' : 'btn-primary']"
:disabled="disableContinue"
@click="$emit('apply'); $emit('close')"
>
{{ applyText }}
OK
</button>
</slot>
</div>
</div>
</div>
</TransitionChild>
</div>
</div>
</TransitionRoot>
</transition>
</template>
<script>
import { TransitionChild, TransitionRoot } from '@headlessui/vue';
import { ref } from 'vue';
export default {
props: {
showModal: Boolean,
noCancel: {
show: {
type: Boolean,
required: false,
default: false,
default: null,
},
autoWidth: Boolean,
headerText: String,
cancelText: {
type: String,
required: false,
default: "Cancel",
},
applyText: {
type: String,
required: false,
default: "Apply",
},
applyDangerous: Boolean,
disableContinue: Boolean,
fullWidth: Boolean,
},
components: {
TransitionChild,
TransitionRoot,
setup(props, { emit }) {
const show_ = ref(false);
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: [
'apply',
'cancel',
'close',
'after-leave',
]
};
</script>

View File

@ -1,27 +1,55 @@
<template>
<ModalPopup
:showModal="show"
:headerText="headerText"
autoWidth
:disableContinue="!valid"
@cancel="cancel()"
@apply="apply()"
<ModalConfirm
:show="show ?? show_"
:clickAwayCancels="clickAwayCancels"
:confirmDangerous="opts_.confirmDangerous ?? confirmDangerous"
:cancelText="cancelText"
:confirmText="confirmText"
: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
v-for="input in inputs"
v-else
v-for="input in inputs_"
:key="input.key"
>
<label
v-if="input.label"
class="block text-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
:class="[!['button', 'checkbox', 'color', 'file', 'radio', 'range'].includes(input.type) ? 'input-textlike w-full' : input.type == 'checkbox' ? 'input-checkbox' : '']"
v-bind="{...input.props}"
v-else
v-bind="{ ...input.props }"
:type="input.type"
:value="output[input.key] ?? input.default"
: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
v-if="input.feedback"
@ -31,100 +59,194 @@
<span class="text-feedback text-error">{{ input.feedback }}</span>
</div>
</div>
</ModalPopup>
</ModalConfirm>
</template>
<script>
import { ref, computed, nextTick } from 'vue';
import { ref, nextTick } from 'vue';
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 {
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 }) {
const show = ref(false);
const headerText = ref("");
const inputs = ref([]);
const output = ref({});
const valid = ref(false);
const apply = ref(() => null);
const cancel = ref(() => null);
const show_ = ref(false);
const headerText_ = ref("");
const opts_ = ref({});
const inputs_ = ref([]);
const valid_ = ref(false);
const resolver_ = ref(null);
const defaultInputRef_ = ref();
const defaultInputValue_ = ref("");
const inputRefs_ = ref([]);
/**
* Promise with method to add input to prompt
* @typedef {Object} ModalPromptPromise
* @property {function} addInput
*/
/**
* Prompt user for input
* @param {string} header - Text to display in popup header
* @returns {ModalPromptPromise} - Resolves object with responses or null if cancelled
*/
const prompt = (header) => {
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);
const ask = (headerText, inputs = [], opts = {}) => {
const prom = new Promise(resolve => {
opts_.value = opts;
headerText_.value = headerText;
inputs_.value = inputs;
resolver_.value = resolve;
show_.value = true;
nextTick(() => {
if (inputs.length) {
inputRefs_.value[0].focus();
} else {
defaultInputRef_.value.focus();
}
});
});
/**
* Callback to validate input, cannot be arrow function
* @callback ModalPromptInputValidationCallback
* @param {any} - value of input
* @returns {boolean} - true if valid, false otherwise
* Add a text input
* @param {String} key - Key to index output object
* @param {String} label - Text to go in label
* @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
*/
/**
* Add an input to the prompt
* @param {string} key - object key for result
* @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) => {
prom.addTextInput = (key, label, defaultValue, placeholder, validate = () => null, props = {}) => {
/**
* @type {ModalPromptInputObj<String>}
*/
const input = {
key,
type,
label,
type: 'text',
value: defaultValue,
placeholder,
default: defaultValue,
props,
validate,
props: {...props, class: 'input-textlike ' + (props.class ?? '')},
feedback: '',
}
output.value[key] = defaultValue;
input.validate = validate?.bind(input),
inputs.value.push(input);
inputs_.value.push(input);
return prom;
}
/**
* 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;
}
prom.addInput = addInput;
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 = () => {
valid.value = inputs.value.every(input => input.validate?.(output.value[input.key]) ?? true);
}
valid_.value = inputs_.value.every(input => input.feedback === null);
};
return {
show,
headerText,
inputs,
output,
valid,
apply,
show_,
opts_,
headerText_,
inputs_,
valid_,
resolver_,
defaultInputRef_,
defaultInputValue_,
inputRefs_,
ask,
confirm,
cancel,
prompt,
reset,
validate,
}
},
components: {
ModalPopup,
ModalConfirm,
ExclamationCircleIcon,
}
},
emits: [
'confirm',
'cancel',
'after-leave',
]
}
</script>