Record.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. <template>
  2. <v-layout class="flex">
  3. <div
  4. v-for="(field, index) in fields"
  5. :key="index"
  6. :class="index == fields.length - 1 ? 'flex-grow-1' : ''"
  7. >
  8. <v-text-field
  9. ref="input"
  10. v-model="field.value"
  11. :label="hideLabel ? '' : field.label"
  12. :class="hideLabel ? 'pt-0' : ''"
  13. :disabled="disabled"
  14. :readonly="readonly"
  15. :placeholder="required && !field.optional ? ' ' : '(optional)'"
  16. :hide-details="!content.length || !($v.fields.$each[index].$invalid || $v.fields[index].$invalid)"
  17. :error="$v.fields.$each[index].$invalid || $v.fields[index].$invalid"
  18. :error-messages="fieldErrorMessages(index)"
  19. :style="{ width: fieldWidth(index) }"
  20. :append-icon="index == fields.length-1 && !readonly && !disabled ? appendIcon : ''"
  21. @click:append="$emit('remove', $event)"
  22. @input="inputHandler()"
  23. @paste.prevent="pasteHandler($event)"
  24. @keydown.8="backspaceHandler(index, $event)"
  25. @keydown.32="spaceHandler(index, $event)"
  26. @keydown.35="endHandler($event)"
  27. @keydown.36="homeHandler($event)"
  28. @keydown.37="leftHandler(index, $event)"
  29. @keydown.39="rightHandler(index, $event)"
  30. @keydown.46="deleteHandler(index, $event)"
  31. @keyup="(e) => $emit('keyup', e)"
  32. />
  33. <span
  34. ref="mirror"
  35. aria-hidden="true"
  36. style="opacity: 0; position: absolute; width: auto; white-space: pre; z-index: -1"
  37. />
  38. {{ errorMessages.join(' ') }}
  39. </div>
  40. </v-layout>
  41. </template>
  42. <script>
  43. import { requiredUnless } from 'vuelidate/lib/validators';
  44. export default {
  45. name: 'Record',
  46. components: {
  47. },
  48. props: {
  49. content: {
  50. type: String,
  51. required: true,
  52. },
  53. errorMessages: {
  54. type: Array,
  55. default: () => [],
  56. },
  57. disabled: {
  58. type: Boolean,
  59. default: false,
  60. },
  61. readonly: {
  62. type: Boolean,
  63. default: false,
  64. },
  65. required: {
  66. type: Boolean,
  67. default: true,
  68. },
  69. hideLabel: {
  70. type: Boolean,
  71. default: false,
  72. },
  73. appendIcon: {
  74. type: String,
  75. required: false,
  76. },
  77. },
  78. data: () => ({
  79. errors: {
  80. required: ' ',
  81. },
  82. fields: [
  83. { label: 'Value', validations: {} },
  84. ],
  85. value: '',
  86. }),
  87. watch: {
  88. content: function () {
  89. this.update(this.content);
  90. }
  91. },
  92. beforeMount() {
  93. // Initialize per-field value storage
  94. this.fields.forEach((field) => {
  95. this.$set(field, 'value', '');
  96. });
  97. // Update internal and graphical representation
  98. this.update(this.content);
  99. },
  100. validations() {
  101. const validations = {
  102. fields: {
  103. $each: {
  104. value: this.required ? { required: requiredUnless('optional') } : {},
  105. },
  106. },
  107. };
  108. validations.fields = this.fields.reduce(
  109. (acc, field, index) => {
  110. acc[index] = { value: field.validations };
  111. return acc;
  112. },
  113. validations.fields,
  114. );
  115. return validations;
  116. },
  117. methods: {
  118. fieldErrorMessages(index) {
  119. const fieldValidationStatus = (fields, index) => Object.keys(fields[index].value.$params).map(
  120. name => ({ passed: fields[index].value[name], message: this.errors[name] }),
  121. );
  122. const validationStatus = [
  123. ...fieldValidationStatus(this.$v.fields, index),
  124. ...fieldValidationStatus(this.$v.fields.$each, index),
  125. ];
  126. return validationStatus.filter(val => !val.passed).map(val => val.message || 'Invalid input.');
  127. },
  128. fieldWidth(index) {
  129. let ret = 'auto';
  130. const inputs = this.$refs.input;
  131. const mirrors = this.$refs.mirror;
  132. if (index < this.fields.length - 1 && inputs && mirrors) {
  133. const mirror = mirrors[index];
  134. while (mirror.childNodes.length) {
  135. mirror.removeChild(mirror.childNodes[0]);
  136. }
  137. const style = window.getComputedStyle(inputs[index].$el);
  138. mirror.style.border = style.getPropertyValue('border');
  139. mirror.style.fontSize = style.getPropertyValue('font-size');
  140. mirror.style.fontFamily = style.getPropertyValue('font-family');
  141. mirror.style.fontWeight = style.getPropertyValue('font-weight');
  142. mirror.style.fontStyle = style.getPropertyValue('font-style');
  143. mirror.style.fontFeatureSettings = style.getPropertyValue('font-feature-settings');
  144. mirror.style.fontKerning = style.getPropertyValue('font-kerning');
  145. mirror.style.fontStretch = style.getPropertyValue('font-stretch');
  146. mirror.style.fontVariant = style.getPropertyValue('font-variant');
  147. mirror.style.fontVariantCaps = style.getPropertyValue('font-variant-caps');
  148. mirror.style.fontVariantLigatures = style.getPropertyValue('font-variant-ligatures');
  149. mirror.style.fontVariantNumeric = style.getPropertyValue('font-variant-numeric');
  150. mirror.style.letterSpacing = style.getPropertyValue('letter-spacing');
  151. mirror.style.padding = style.getPropertyValue('padding');
  152. mirror.style.textTransform = style.getPropertyValue('text-transform');
  153. mirror.appendChild(document.createTextNode(`${this.fields[index].value} `));
  154. ret = mirror.getBoundingClientRect().width;
  155. mirror.removeChild(mirror.childNodes[0]);
  156. mirror.appendChild(document.createTextNode(`${this.fields[index].label} `));
  157. ret = Math.max(ret, mirror.getBoundingClientRect().width);
  158. ret += 'px';
  159. }
  160. return ret;
  161. },
  162. focus() {
  163. this.$refs.input[0].focus();
  164. },
  165. async update(value, caretPosition) {
  166. await this.$nextTick();
  167. // Right-trim if the cursor position is not after the last character
  168. let trimmed = value.replace(/ +$/g, '');
  169. const n = (trimmed.match(/ /g) || []).length;
  170. const diff = Math.max(0, (this.fields.length - 1) - n);
  171. trimmed += ' '.repeat(diff);
  172. if (caretPosition < trimmed.length) {
  173. value = trimmed;
  174. }
  175. // Only emit update event if there's news
  176. const dirty = (value !== this.value);
  177. if (dirty) {
  178. this.value = value;
  179. this.$emit('update:content', this.value);
  180. }
  181. // Always update fields as left-side fields with empty neighbor might have a trailing space
  182. // This case does not change the record value, but the field representation needs an update
  183. this.updateFields();
  184. if (caretPosition !== undefined) {
  185. await this.setPosition(caretPosition);
  186. }
  187. },
  188. positionAfterDelimiter(index) {
  189. const ref = this.$refs.input[index].$refs.input;
  190. return index > 0 && ref.selectionStart === 0 && ref.selectionEnd === 0;
  191. },
  192. positionBeforeDelimiter(index) {
  193. return index < this.fields.length - 1
  194. && this.$refs.input[index].$refs.input.selectionStart === this.fields[index].value.length;
  195. },
  196. spaceHandler(index, event) {
  197. if (!this.positionBeforeDelimiter(index)) {
  198. return;
  199. }
  200. const length = this.fields.slice(index + 1)
  201. .map(field => field.value.length)
  202. .reduce((acc, curr) => acc + curr, 0);
  203. if (length === 0 || this.fields[this.fields.length - 1].value.length > 0) {
  204. return this.rightHandler(index, event);
  205. }
  206. },
  207. backspaceHandler(index, event) {
  208. if (!this.positionAfterDelimiter(index)) {
  209. return;
  210. }
  211. event.preventDefault();
  212. const pos = this.getPosition();
  213. this.update(this.value.substr(0, pos - 1) + this.value.substr(pos), pos - 1);
  214. },
  215. deleteHandler(index, event) {
  216. if (!this.positionBeforeDelimiter(index)) {
  217. return;
  218. }
  219. event.preventDefault();
  220. const pos = this.getPosition();
  221. this.update(this.value.substr(0, pos) + this.value.substr(pos + 1), pos);
  222. },
  223. leftHandler(index, event) {
  224. if (!this.positionAfterDelimiter(index)) {
  225. return;
  226. }
  227. event.preventDefault();
  228. this.setPosition(this.getPosition() - 1);
  229. },
  230. rightHandler(index, event) {
  231. if (!this.positionBeforeDelimiter(index)) {
  232. return;
  233. }
  234. event.preventDefault();
  235. this.setPosition(this.getPosition() + 1);
  236. },
  237. endHandler(event) {
  238. event.preventDefault();
  239. this.setPosition(this.value.length);
  240. },
  241. homeHandler(event) {
  242. event.preventDefault();
  243. this.setPosition(0);
  244. },
  245. inputHandler() {
  246. const pos = this.getPosition();
  247. const value = this.fields.map(field => field.value).join(' ');
  248. this.update(value, pos);
  249. },
  250. pasteHandler(e) {
  251. let value = this.fields.map(field => field.value).join(' ');
  252. const pos = this.getPosition();
  253. const clipboardData = e.clipboardData.getData('text');
  254. // number of fields covered by this paste, minus 1 (given by number of spaces in the clipboard text, bounded from
  255. // above by the number of fields (minus 1) at or to the right of the caret position
  256. const nBeforeCaret = (value.slice(0, pos).match(/ /g) || []).length
  257. const n = Math.min((clipboardData.match(/ /g) || []).length, this.fields.length - 1 - nBeforeCaret);
  258. // Insert clipboard text and remove up to n spaces form the right
  259. value = value.slice(0, pos) + clipboardData + value.slice(pos);
  260. value = value.replace(new RegExp(` {0,${n}}$`,'g'), '');
  261. this.update(value, pos + clipboardData.length);
  262. },
  263. async setPosition(pos) {
  264. await this.$nextTick();
  265. let i = 0;
  266. while (pos > this.fields[i].value.length && i + 1 < this.fields.length) {
  267. pos -= this.fields[i].value.length + 1;
  268. i++;
  269. }
  270. this.$refs.input[i].$refs.input.setSelectionRange(pos, pos);
  271. this.$refs.input[i].$refs.input.focus();
  272. },
  273. getPosition() {
  274. let caretPosition;
  275. const refs = this.$refs.input;
  276. const dirty = refs.findIndex(ref => ref.$refs.input === document.activeElement);
  277. if (dirty >= 0) {
  278. caretPosition = refs[dirty].$refs.input.selectionStart;
  279. for (let i = 0; i < dirty; i++) {
  280. caretPosition += refs[i].$refs.input.value.length + 1;
  281. }
  282. }
  283. return caretPosition;
  284. },
  285. updateFields() {
  286. let values = this.value.split(' ');
  287. const last = values.slice(this.fields.length - 1).join(' ');
  288. values = values.slice(0, this.fields.length - 1);
  289. values = values.concat([last]);
  290. // Make sure to reset trailing fields if value does not have enough spaces
  291. this.fields.forEach((foo, i) => {
  292. this.$set(this.fields[i], 'value', values[i] || '');
  293. });
  294. },
  295. },
  296. };
  297. </script>
  298. <style>
  299. </style>