Abstracted text fields into smart input components
This commit is contained in:
@@ -49,6 +49,7 @@
|
||||
import HitDiceListTile from '/imports/ui/components/HitDiceListTile.Story.vue';
|
||||
import IconSearch from '/imports/ui/components/IconSearch.Story.vue';
|
||||
import SkillListTile from '/imports/ui/components/SkillListTile.Story.vue';
|
||||
import SmartInput from '/imports/ui/components/global/SmartInput.Story.vue';
|
||||
import ToolbarLayout from '/imports/ui/layouts/ToolbarLayout.vue';
|
||||
|
||||
export default {
|
||||
@@ -64,6 +65,7 @@
|
||||
HitDiceListTile,
|
||||
IconSearch,
|
||||
SkillListTile,
|
||||
SmartInput,
|
||||
ToolbarLayout,
|
||||
},
|
||||
data(){ return {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
v-for="(attribute, index) in attributes"
|
||||
:key="attribute._id"
|
||||
:attribute="attribute"
|
||||
@change="e => change(index, e)"
|
||||
@change="(e, ack) => change(index, e, ack)"
|
||||
/>
|
||||
<div class="ma-4" v-for="(attribute, index) in attributes">
|
||||
{{attribute}}
|
||||
@@ -39,14 +39,17 @@
|
||||
],
|
||||
}},
|
||||
methods: {
|
||||
change(index, e){
|
||||
for (let i in e){
|
||||
this.attributes[index][i] = e[i].trim();
|
||||
}
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
log: console.log,
|
||||
change(index, e, ack){
|
||||
// Take a while to write the attribute
|
||||
setTimeout(() => {
|
||||
for (let i in e){
|
||||
let val = e[i];
|
||||
if (typeof val === 'string') val = val.trim();
|
||||
this.attributes[index][i] = val;
|
||||
}
|
||||
if (ack) ack();
|
||||
}, 600)
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -3,41 +3,41 @@
|
||||
<text-field
|
||||
label="Name"
|
||||
:value="attribute.name"
|
||||
@input="name => $emit('change', {name})"
|
||||
@change="(name, ack) => $emit('change', {name}, ack)"
|
||||
/>
|
||||
<text-field
|
||||
label="Variable name"
|
||||
:value="attribute.variableName"
|
||||
@input="variableName => $emit('change', {variableName})"
|
||||
@change="(variableName, ack) => $emit('change', {variableName}, ack)"
|
||||
hint="Use this name in formulae to reference this attribute"
|
||||
/>
|
||||
<text-field
|
||||
label="Base Value"
|
||||
type="number"
|
||||
:value="attribute.baseValue"
|
||||
@input="baseValue => $emit('change', {baseValue})"
|
||||
@change="(baseValue, ack) => $emit('change', {baseValue}, ack)"
|
||||
hint="This is the value of the attribute before effects are applied"
|
||||
/>
|
||||
<text-field
|
||||
label="Damage"
|
||||
type="number"
|
||||
:value="-attribute.adjustment"
|
||||
@input="damage => $emit('change', {adjustment: -damage || null})"
|
||||
@change="(damage, ack) => $emit('change', {adjustment: -damage || null}, ack)"
|
||||
/>
|
||||
<v-select
|
||||
<smart-select
|
||||
label="Type"
|
||||
color="accent"
|
||||
:items="attributeTypes"
|
||||
:value="attribute.type"
|
||||
:menu-props="{auto: true, lazy: true}"
|
||||
@input="type => $emit('change', {type})"
|
||||
@change="(type, ack) => $emit('change', {type}, ack)"
|
||||
/>
|
||||
<v-switch
|
||||
label="Allow decimal values"
|
||||
:value="attribute.decimal"
|
||||
@change="e => $emit('change', {decimal: e})"
|
||||
/>
|
||||
<v-select
|
||||
<smart-select
|
||||
label="Reset"
|
||||
color="accent"
|
||||
append-icon="arrow_drop_down"
|
||||
@@ -45,13 +45,13 @@
|
||||
:items="resetOptions"
|
||||
:value="attribute.reset"
|
||||
:menu-props="{auto: true, lazy: true}"
|
||||
@input="reset => $emit('change', {reset})"
|
||||
@change="(reset, ack) => $emit('change', {reset}, ack)"
|
||||
/>
|
||||
<text-field
|
||||
label="Reset Multiplier"
|
||||
type="number"
|
||||
:value="attribute.resetMultiplier"
|
||||
@input="resetMultiplier => $emit('change', {resetMultiplier})"
|
||||
@change="(resetMultiplier, ack) => $emit('change', {resetMultiplier}, ack)"
|
||||
hint="Some attributes, like hit dice, only reset by half their total on a long rest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
92
app/imports/ui/components/global/SmartInput.Story.vue
Normal file
92
app/imports/ui/components/global/SmartInput.Story.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template lang="html">
|
||||
<v-container grid-list-lg>
|
||||
<v-layout row wrap align-center>
|
||||
|
||||
<v-flex xs6>
|
||||
<text-field
|
||||
:value="value1"
|
||||
@change="value1Change"
|
||||
/>
|
||||
</v-flex>
|
||||
<v-flex xs6>
|
||||
<div class="flex">
|
||||
{{value1}}
|
||||
</div>
|
||||
</v-flex>
|
||||
|
||||
<v-flex xs6>
|
||||
<text-field
|
||||
:value="changingValue"
|
||||
@change="(val, ack) => {changingValue = val; ack()}"
|
||||
/>
|
||||
</v-flex>
|
||||
<v-flex xs6>
|
||||
{{changingValue}}
|
||||
</v-flex>
|
||||
|
||||
<v-flex xs6>
|
||||
<text-area
|
||||
:value="value2"
|
||||
@change="value2Change"
|
||||
label="text-area"
|
||||
/>
|
||||
</v-flex>
|
||||
<v-flex xs6>
|
||||
{{value2}}
|
||||
</v-flex>
|
||||
|
||||
<v-flex xs6>
|
||||
<smart-select
|
||||
:value="value3"
|
||||
:items="['meep', 'moop', 'maap']"
|
||||
label="select"
|
||||
@change="(val, ack) => {setTimeout(() => {value3 = val; ack()}, 700)}"
|
||||
/>
|
||||
</v-flex>
|
||||
<v-flex xs6>
|
||||
{{value3}}
|
||||
</v-flex>
|
||||
|
||||
</v-layout>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data(){ return {
|
||||
value1: 'Five letters minimum, always trimmed',
|
||||
value2: 'Takes 2s to write',
|
||||
value3: 'meep',
|
||||
changingValue: `Changes every 3s ${Math.random().toFixed(4)}`,
|
||||
}},
|
||||
methods: {
|
||||
value1Change(val, ack){
|
||||
let error;
|
||||
val = val.trim();
|
||||
if (!val || val.length < 5){
|
||||
error = "Too short";
|
||||
} else {
|
||||
this.value1 = val;
|
||||
}
|
||||
ack(error);
|
||||
},
|
||||
value2Change(val, ack){
|
||||
setTimeout(() => {
|
||||
this.value2 = val;
|
||||
ack();
|
||||
}, 2000);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
setInterval(() => {
|
||||
this.changingValue = `Changes every 3s ${Math.random().toFixed(4)}`;
|
||||
}, 3000);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.layout {
|
||||
margin: 20px 0;
|
||||
}
|
||||
</style>
|
||||
93
app/imports/ui/components/global/SmartInput.js
Normal file
93
app/imports/ui/components/global/SmartInput.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Mixin to handle inputs that update the database.
|
||||
* Won't bash the field's value while it's focused, even if the database trims
|
||||
* or otherwise sanitizes the data captured.
|
||||
*
|
||||
* Emits a change event that requires acknowledgement with an optional error
|
||||
* message if something went wrong
|
||||
*/
|
||||
import { _ } from 'underscore';
|
||||
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
data(){ return {
|
||||
error: false,
|
||||
errorMessages: [],
|
||||
focused: false,
|
||||
loading: false,
|
||||
dirty: false,
|
||||
safeValue: this.value,
|
||||
inputValue: this.value,
|
||||
}},
|
||||
props: {
|
||||
value: [String, Number],
|
||||
debounceTime: {
|
||||
type: Number,
|
||||
default: 750,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
focused(newFocus){
|
||||
// If the value updated while we were focused, show it now on defocus
|
||||
// but not if we are waiting for our own writes to get persisted
|
||||
// and not if there is an error in our input
|
||||
if (!newFocus && !this.dirty && !this.error){
|
||||
this.forceSafeValueUpdate();
|
||||
}
|
||||
// Start the loading bar on defocus if the input is dirty
|
||||
// It might be a lie, we aren't doing the work yet, but it feels laggy
|
||||
// to defocus an element and then it starts working after a delay
|
||||
if (!newFocus, this.dirty){
|
||||
this.loading = true;
|
||||
}
|
||||
},
|
||||
dirty(newDirty){
|
||||
// Our changes were acknowledged, weren't in error, and we aren't focused,
|
||||
// make sure the internal value matches the database value
|
||||
if (!newDirty && !this.focused && !this.error){
|
||||
this.forceSafeValueUpdate();
|
||||
}
|
||||
},
|
||||
value(newValue){
|
||||
if (!this.focused){
|
||||
this.safeValue = newValue;
|
||||
}
|
||||
},
|
||||
safeValue(newSafeValue){
|
||||
// The safe value only gets updated from the parent, so it must be valid
|
||||
this.error = false;
|
||||
this.errorMessages = [];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
input(val){
|
||||
this.$emit('input', val);
|
||||
this.inputValue = val;
|
||||
this.dirty = true;
|
||||
this.debouncedChange(val);
|
||||
},
|
||||
acknowledgeChange(error){
|
||||
this.loading = false;
|
||||
this.dirty = false;
|
||||
this.error = !!error;
|
||||
this.errorMessages = error || [];
|
||||
},
|
||||
change(val){
|
||||
this.dirty = true;
|
||||
if (val === this.value) return;
|
||||
if (this.hasChangeListener) this.loading = true;
|
||||
this.$emit('change', val, this.acknowledgeChange);
|
||||
},
|
||||
hasChangeListener(){
|
||||
return this.$listeners && this.$listeners.change
|
||||
},
|
||||
forceSafeValueUpdate(){
|
||||
// hack to force the value to update on the child component
|
||||
this.safeValue = null
|
||||
this.$nextTick(() => this.safeValue = this.value);
|
||||
},
|
||||
},
|
||||
created(){
|
||||
this.debouncedChange = _.debounce(this.change, this.debounceTime);
|
||||
},
|
||||
};
|
||||
20
app/imports/ui/components/global/SmartSelect.vue
Normal file
20
app/imports/ui/components/global/SmartSelect.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template lang="html">
|
||||
<v-select
|
||||
v-bind="$attrs"
|
||||
:loading="loading"
|
||||
:error-messages="errorMessages"
|
||||
:value="safeValue"
|
||||
:menu-props="{auto: true, lazy: true}"
|
||||
@change="change"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SmartInput from '/imports/ui/components/global/SmartInput.js';
|
||||
|
||||
export default {
|
||||
mixins: [SmartInput],
|
||||
};
|
||||
</script>
|
||||
19
app/imports/ui/components/global/TextArea.vue
Normal file
19
app/imports/ui/components/global/TextArea.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template lang="html">
|
||||
<v-textarea
|
||||
v-bind="$attrs"
|
||||
:loading="loading"
|
||||
:error-messages="errorMessages"
|
||||
:value="safeValue"
|
||||
@input="input"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SmartInput from '/imports/ui/components/global/SmartInput.js';
|
||||
|
||||
export default {
|
||||
mixins: [SmartInput],
|
||||
};
|
||||
</script>
|
||||
@@ -1,6 +1,8 @@
|
||||
<template lang="html">
|
||||
<v-text-field
|
||||
:error="error"
|
||||
v-bind="$attrs"
|
||||
:loading="loading"
|
||||
:error-messages="errorMessages"
|
||||
:value="safeValue"
|
||||
@input="input"
|
||||
@focus="focused = true"
|
||||
@@ -9,67 +11,9 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/*
|
||||
* Component to handle text fields that update the database.
|
||||
* Won't bash the text field while it's focused, even if the database trims
|
||||
* or otherwise sanitizes the input.
|
||||
* Emits input events on every input and emits debounced change
|
||||
* events based on the debounceTime set in its props.
|
||||
* Losing focus will set the value to the database's current stored value.
|
||||
*
|
||||
* Because attributes that aren't properties are passed to the root element,
|
||||
* this is a drop-in replacement for v-text-field
|
||||
*
|
||||
* TODO extract this functionality as a mixin, and share to textarea
|
||||
*/
|
||||
import { _ } from 'underscore';
|
||||
import SmartInput from '/imports/ui/components/global/SmartInput.js';
|
||||
|
||||
export default {
|
||||
data(){ return {
|
||||
safeValue: this.value,
|
||||
focused: false,
|
||||
error: false,
|
||||
errorMessages: null,
|
||||
}},
|
||||
props: {
|
||||
value: [String, Number],
|
||||
debounceTime: {
|
||||
type: Number,
|
||||
default: 250,
|
||||
},
|
||||
validator: Function,
|
||||
},
|
||||
watch: {
|
||||
focused(isFocused){
|
||||
if (!isFocused){
|
||||
this.safeValue = this.value;
|
||||
}
|
||||
},
|
||||
value(newValue){
|
||||
if (!this.focused){
|
||||
this.safeValue = this.value;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
input(val){
|
||||
this.$emit('input', val);
|
||||
this.update(val);
|
||||
if (this.validator){
|
||||
try {
|
||||
this.validator(val);
|
||||
this.error = false;
|
||||
this.errorMessages = null;
|
||||
} catch(e){
|
||||
this.error = true;
|
||||
this.errorMessages = e.message;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
created(){
|
||||
this.update = _.debounce(val => {
|
||||
this.$emit('change', val);
|
||||
}, this.debounceTime);
|
||||
},
|
||||
mixins: [SmartInput],
|
||||
};
|
||||
</script>
|
||||
|
||||
9
app/imports/ui/components/global/globalIndex.js
Normal file
9
app/imports/ui/components/global/globalIndex.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import Vue from "vue";
|
||||
// Global components
|
||||
import TextField from '/imports/ui/components/global/TextField.vue';
|
||||
import TextArea from '/imports/ui/components/global/TextArea.vue';
|
||||
import SmartSelect from '/imports/ui/components/global/SmartSelect.vue';
|
||||
|
||||
Vue.component("text-field", TextField);
|
||||
Vue.component("text-area", TextArea);
|
||||
Vue.component("smart-select", SmartSelect);
|
||||
@@ -5,13 +5,9 @@ import store from "/imports/ui/vuexStore.js";
|
||||
import VueMeteorTracker from 'vue-meteor-tracker';
|
||||
import AppLayout from '/imports/ui/layouts/AppLayout.vue';
|
||||
import router from "/imports/ui/router.js";
|
||||
import "vuetify/dist/vuetify.min.css";
|
||||
import theme from '/imports/ui/theme.js';
|
||||
|
||||
// Global components
|
||||
import TextField from '/imports/ui/components/global/TextField.vue';
|
||||
|
||||
Vue.component("text-field", TextField);
|
||||
import "vuetify/dist/vuetify.min.css";
|
||||
import '/imports/ui/components/global/globalIndex.js';
|
||||
|
||||
Vue.use(VueMeteorTracker);
|
||||
Vue.use(Vuetify, {
|
||||
|
||||
Reference in New Issue
Block a user