overhaul modals from ground up
This commit is contained in:
parent
75df808dad
commit
97e733d58e
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue