Abstracted text fields into smart input components

This commit is contained in:
Stefan Zermatten
2019-02-07 13:53:44 +02:00
parent 5142b8e1a0
commit 4b25373c7c
10 changed files with 264 additions and 86 deletions

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View 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>

View 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);
},
};

View 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>

View 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>

View File

@@ -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>

View 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);

View File

@@ -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, {