diff --git a/app/imports/ui/StoryBook.vue b/app/imports/ui/StoryBook.vue
index 1c5a01df..3a09b332 100644
--- a/app/imports/ui/StoryBook.vue
+++ b/app/imports/ui/StoryBook.vue
@@ -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 {
diff --git a/app/imports/ui/components/AttributeEdit.Story.vue b/app/imports/ui/components/AttributeEdit.Story.vue
index d12839c7..0e1a7d9b 100644
--- a/app/imports/ui/components/AttributeEdit.Story.vue
+++ b/app/imports/ui/components/AttributeEdit.Story.vue
@@ -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)"
/>
{{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)
+ },
},
};
diff --git a/app/imports/ui/components/AttributeEdit.vue b/app/imports/ui/components/AttributeEdit.vue
index 900b0237..0d6099d0 100644
--- a/app/imports/ui/components/AttributeEdit.vue
+++ b/app/imports/ui/components/AttributeEdit.vue
@@ -3,41 +3,41 @@
$emit('change', {name})"
+ @change="(name, ack) => $emit('change', {name}, ack)"
/>
$emit('change', {variableName})"
+ @change="(variableName, ack) => $emit('change', {variableName}, ack)"
hint="Use this name in formulae to reference this attribute"
/>
$emit('change', {baseValue})"
+ @change="(baseValue, ack) => $emit('change', {baseValue}, ack)"
hint="This is the value of the attribute before effects are applied"
/>
$emit('change', {adjustment: -damage || null})"
+ @change="(damage, ack) => $emit('change', {adjustment: -damage || null}, ack)"
/>
- $emit('change', {type})"
+ @change="(type, ack) => $emit('change', {type}, ack)"
/>
$emit('change', {decimal: e})"
/>
- $emit('change', {reset})"
+ @change="(reset, ack) => $emit('change', {reset}, ack)"
/>
$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"
/>
diff --git a/app/imports/ui/components/global/SmartInput.Story.vue b/app/imports/ui/components/global/SmartInput.Story.vue
new file mode 100644
index 00000000..f8bc6ee1
--- /dev/null
+++ b/app/imports/ui/components/global/SmartInput.Story.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+ {{value1}}
+
+
+
+
+ {changingValue = val; ack()}"
+ />
+
+
+ {{changingValue}}
+
+
+
+
+
+
+ {{value2}}
+
+
+
+ {setTimeout(() => {value3 = val; ack()}, 700)}"
+ />
+
+
+ {{value3}}
+
+
+
+
+
+
+
+
+
diff --git a/app/imports/ui/components/global/SmartInput.js b/app/imports/ui/components/global/SmartInput.js
new file mode 100644
index 00000000..3b1ed212
--- /dev/null
+++ b/app/imports/ui/components/global/SmartInput.js
@@ -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);
+ },
+};
diff --git a/app/imports/ui/components/global/SmartSelect.vue b/app/imports/ui/components/global/SmartSelect.vue
new file mode 100644
index 00000000..21aa896c
--- /dev/null
+++ b/app/imports/ui/components/global/SmartSelect.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/app/imports/ui/components/global/TextArea.vue b/app/imports/ui/components/global/TextArea.vue
new file mode 100644
index 00000000..7e5c242e
--- /dev/null
+++ b/app/imports/ui/components/global/TextArea.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/app/imports/ui/components/global/TextField.vue b/app/imports/ui/components/global/TextField.vue
index 3e845f44..a00142a2 100644
--- a/app/imports/ui/components/global/TextField.vue
+++ b/app/imports/ui/components/global/TextField.vue
@@ -1,6 +1,8 @@
diff --git a/app/imports/ui/components/global/globalIndex.js b/app/imports/ui/components/global/globalIndex.js
new file mode 100644
index 00000000..50123997
--- /dev/null
+++ b/app/imports/ui/components/global/globalIndex.js
@@ -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);
diff --git a/app/imports/ui/vueSetup.js b/app/imports/ui/vueSetup.js
index 6fbba31e..76d2e18d 100644
--- a/app/imports/ui/vueSetup.js
+++ b/app/imports/ui/vueSetup.js
@@ -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, {