123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324 |
- <template>
- <v-layout class="flex">
- <div
- v-for="(field, index) in fields"
- :key="index"
- :class="index == fields.length - 1 ? 'flex-grow-1' : ''"
- >
- <v-text-field
- ref="input"
- v-model="field.value"
- :label="hideLabel ? '' : field.label"
- :class="hideLabel ? 'pt-0' : ''"
- :disabled="disabled"
- :readonly="readonly"
- :placeholder="required && !field.optional ? ' ' : '(optional)'"
- :hide-details="!content.length || !($v.fields.$each[index].$invalid || $v.fields[index].$invalid)"
- :error="$v.fields.$each[index].$invalid || $v.fields[index].$invalid"
- :error-messages="fieldErrorMessages(index)"
- :style="{ width: fieldWidth(index) }"
- :append-icon="index == fields.length-1 && !readonly && !disabled ? appendIcon : ''"
- @click:append="$emit('remove', $event)"
- @input="inputHandler()"
- @paste.prevent="pasteHandler($event)"
- @keydown.8="backspaceHandler(index, $event)"
- @keydown.32="spaceHandler(index, $event)"
- @keydown.35="endHandler($event)"
- @keydown.36="homeHandler($event)"
- @keydown.37="leftHandler(index, $event)"
- @keydown.39="rightHandler(index, $event)"
- @keydown.46="deleteHandler(index, $event)"
- @keyup="(e) => $emit('keyup', e)"
- />
- <span
- ref="mirror"
- aria-hidden="true"
- style="opacity: 0; position: absolute; width: auto; white-space: pre; z-index: -1"
- />
- {{ errorMessages.join(' ') }}
- </div>
- </v-layout>
- </template>
- <script>
- import { requiredUnless } from 'vuelidate/lib/validators';
- export default {
- name: 'Record',
- components: {
- },
- props: {
- content: {
- type: String,
- required: true,
- },
- errorMessages: {
- type: Array,
- default: () => [],
- },
- disabled: {
- type: Boolean,
- default: false,
- },
- readonly: {
- type: Boolean,
- default: false,
- },
- required: {
- type: Boolean,
- default: true,
- },
- hideLabel: {
- type: Boolean,
- default: false,
- },
- appendIcon: {
- type: String,
- required: false,
- },
- },
- data: () => ({
- errors: {
- required: ' ',
- },
- fields: [
- { label: 'Value', validations: {} },
- ],
- value: '',
- }),
- watch: {
- content: function () {
- this.update(this.content);
- }
- },
- beforeMount() {
- // Initialize per-field value storage
- this.fields.forEach((field) => {
- this.$set(field, 'value', '');
- });
- // Update internal and graphical representation
- this.update(this.content);
- },
- validations() {
- const validations = {
- fields: {
- $each: {
- value: this.required ? { required: requiredUnless('optional') } : {},
- },
- },
- };
- validations.fields = this.fields.reduce(
- (acc, field, index) => {
- acc[index] = { value: field.validations };
- return acc;
- },
- validations.fields,
- );
- return validations;
- },
- methods: {
- fieldErrorMessages(index) {
- const fieldValidationStatus = (fields, index) => Object.keys(fields[index].value.$params).map(
- name => ({ passed: fields[index].value[name], message: this.errors[name] }),
- );
- const validationStatus = [
- ...fieldValidationStatus(this.$v.fields, index),
- ...fieldValidationStatus(this.$v.fields.$each, index),
- ];
- return validationStatus.filter(val => !val.passed).map(val => val.message || 'Invalid input.');
- },
- fieldWidth(index) {
- let ret = 'auto';
- const inputs = this.$refs.input;
- const mirrors = this.$refs.mirror;
- if (index < this.fields.length - 1 && inputs && mirrors) {
- const mirror = mirrors[index];
- while (mirror.childNodes.length) {
- mirror.removeChild(mirror.childNodes[0]);
- }
- const style = window.getComputedStyle(inputs[index].$el);
- mirror.style.border = style.getPropertyValue('border');
- mirror.style.fontSize = style.getPropertyValue('font-size');
- mirror.style.fontFamily = style.getPropertyValue('font-family');
- mirror.style.fontWeight = style.getPropertyValue('font-weight');
- mirror.style.fontStyle = style.getPropertyValue('font-style');
- mirror.style.fontFeatureSettings = style.getPropertyValue('font-feature-settings');
- mirror.style.fontKerning = style.getPropertyValue('font-kerning');
- mirror.style.fontStretch = style.getPropertyValue('font-stretch');
- mirror.style.fontVariant = style.getPropertyValue('font-variant');
- mirror.style.fontVariantCaps = style.getPropertyValue('font-variant-caps');
- mirror.style.fontVariantLigatures = style.getPropertyValue('font-variant-ligatures');
- mirror.style.fontVariantNumeric = style.getPropertyValue('font-variant-numeric');
- mirror.style.letterSpacing = style.getPropertyValue('letter-spacing');
- mirror.style.padding = style.getPropertyValue('padding');
- mirror.style.textTransform = style.getPropertyValue('text-transform');
- mirror.appendChild(document.createTextNode(`${this.fields[index].value} `));
- ret = mirror.getBoundingClientRect().width;
- mirror.removeChild(mirror.childNodes[0]);
- mirror.appendChild(document.createTextNode(`${this.fields[index].label} `));
- ret = Math.max(ret, mirror.getBoundingClientRect().width);
- ret += 'px';
- }
- return ret;
- },
- focus() {
- this.$refs.input[0].focus();
- },
- async update(value, caretPosition) {
- await this.$nextTick();
- // Right-trim if the cursor position is not after the last character
- let trimmed = value.replace(/ +$/g, '');
- const n = (trimmed.match(/ /g) || []).length;
- const diff = Math.max(0, (this.fields.length - 1) - n);
- trimmed += ' '.repeat(diff);
- if (caretPosition < trimmed.length) {
- value = trimmed;
- }
- // Only emit update event if there's news
- const dirty = (value !== this.value);
- if (dirty) {
- this.value = value;
- this.$emit('update:content', this.value);
- }
- // Always update fields as left-side fields with empty neighbor might have a trailing space
- // This case does not change the record value, but the field representation needs an update
- this.updateFields();
- if (caretPosition !== undefined) {
- await this.setPosition(caretPosition);
- }
- },
- positionAfterDelimiter(index) {
- const ref = this.$refs.input[index].$refs.input;
- return index > 0 && ref.selectionStart === 0 && ref.selectionEnd === 0;
- },
- positionBeforeDelimiter(index) {
- return index < this.fields.length - 1
- && this.$refs.input[index].$refs.input.selectionStart === this.fields[index].value.length;
- },
- spaceHandler(index, event) {
- if (!this.positionBeforeDelimiter(index)) {
- return;
- }
- const length = this.fields.slice(index + 1)
- .map(field => field.value.length)
- .reduce((acc, curr) => acc + curr, 0);
- if (length === 0 || this.fields[this.fields.length - 1].value.length > 0) {
- return this.rightHandler(index, event);
- }
- },
- backspaceHandler(index, event) {
- if (!this.positionAfterDelimiter(index)) {
- return;
- }
- event.preventDefault();
- const pos = this.getPosition();
- this.update(this.value.substr(0, pos - 1) + this.value.substr(pos), pos - 1);
- },
- deleteHandler(index, event) {
- if (!this.positionBeforeDelimiter(index)) {
- return;
- }
- event.preventDefault();
- const pos = this.getPosition();
- this.update(this.value.substr(0, pos) + this.value.substr(pos + 1), pos);
- },
- leftHandler(index, event) {
- if (!this.positionAfterDelimiter(index)) {
- return;
- }
- event.preventDefault();
- this.setPosition(this.getPosition() - 1);
- },
- rightHandler(index, event) {
- if (!this.positionBeforeDelimiter(index)) {
- return;
- }
- event.preventDefault();
- this.setPosition(this.getPosition() + 1);
- },
- endHandler(event) {
- event.preventDefault();
- this.setPosition(this.value.length);
- },
- homeHandler(event) {
- event.preventDefault();
- this.setPosition(0);
- },
- inputHandler() {
- const pos = this.getPosition();
- const value = this.fields.map(field => field.value).join(' ');
- this.update(value, pos);
- },
- pasteHandler(e) {
- let value = this.fields.map(field => field.value).join(' ');
- const pos = this.getPosition();
- const clipboardData = e.clipboardData.getData('text');
- // number of fields covered by this paste, minus 1 (given by number of spaces in the clipboard text, bounded from
- // above by the number of fields (minus 1) at or to the right of the caret position
- const nBeforeCaret = (value.slice(0, pos).match(/ /g) || []).length
- const n = Math.min((clipboardData.match(/ /g) || []).length, this.fields.length - 1 - nBeforeCaret);
- // Insert clipboard text and remove up to n spaces form the right
- value = value.slice(0, pos) + clipboardData + value.slice(pos);
- value = value.replace(new RegExp(` {0,${n}}$`,'g'), '');
- this.update(value, pos + clipboardData.length);
- },
- async setPosition(pos) {
- await this.$nextTick();
- let i = 0;
- while (pos > this.fields[i].value.length && i + 1 < this.fields.length) {
- pos -= this.fields[i].value.length + 1;
- i++;
- }
- this.$refs.input[i].$refs.input.setSelectionRange(pos, pos);
- this.$refs.input[i].$refs.input.focus();
- },
- getPosition() {
- let caretPosition;
- const refs = this.$refs.input;
- const dirty = refs.findIndex(ref => ref.$refs.input === document.activeElement);
- if (dirty >= 0) {
- caretPosition = refs[dirty].$refs.input.selectionStart;
- for (let i = 0; i < dirty; i++) {
- caretPosition += refs[i].$refs.input.value.length + 1;
- }
- }
- return caretPosition;
- },
- updateFields() {
- let values = this.value.split(' ');
- const last = values.slice(this.fields.length - 1).join(' ');
- values = values.slice(0, this.fields.length - 1);
- values = values.concat([last]);
- // Make sure to reset trailing fields if value does not have enough spaces
- this.fields.forEach((foo, i) => {
- this.$set(this.fields[i], 'value', values[i] || '');
- });
- },
- },
- };
- </script>
- <style>
- </style>
|