While squashing some bugs on and making some updates to my address book, I decided to change the input fields to the inline editable style found on Trello  (show below), Google Sheets, etc.

I'd like to use this component in other apps so we'll be making it a standalone component in this tutorial.

Since this is a single component, it's easier testing in an online sandbox like this one on Sandbox.io instead of creating a full-fledged app.

Firstly, we'll create the folder and files we need by running the following commands:

$ mkdir vue-inline-input
$ cd vue-inline-input
$ mkdir src build
$ touch .gitignore package.json build/rollup.config.js src/entry.js src/InlineInput.vue README.md

Before jumping into writing the actual component, let's take care of the git and build configurations.

The only thing we need to ignore in our .gitignore is the node_modules directory, so add it to the .gitignore file.

Our package.jsonwill contain:

{
  "name": "vue-inline-input",
  "version": "1.0.0",
  "description": "A Vue component for inline editable inputs.",
  "main": "dist/vue-inline-input.ssr.js",
  "module": "dist/vue-inline-input.esm.js",
  "unpkg": "dist/vue-inline-input.min.js",
  "files": [
    "dist/*",
    "src/**/*.vue"
  ],
  "scripts": {
    "build": "cross-env NODE_ENV=production rollup --config build/rollup.config.js",
    "build:ssr": "cross-env NODE_ENV=production rollup --config build/rollup.config.js --format cjs",
    "build:es": "cross-env NODE_ENV=production rollup --config build/rollup.config.js --format es",
    "build:unpkg": "cross-env NODE_ENV=production rollup --config build/rollup.config.js --format iife"
  },
  "author": "Uk Chukundah",
  "license": "MIT",
  "keywords": [
    "vue",
    "inline",
    "input",
    "editable"
  ],
  "repository": {
    "type": "git",
    "url": "git+https://github.com/ukchukx/vue-inline-input"
  },
  "devDependencies": {
    "@rollup/plugin-buble": "^0.21.0",
    "@rollup/plugin-commonjs": "^11.0.1",
    "@rollup/plugin-replace": "^2.3.0",
    "core-js": "^3.6.3",
    "cross-env": "^5.2.0",
    "minimist": "^1.2.0",
    "rollup": "^1.12.1",
    "rollup-plugin-terser": "^5.2.0",
    "rollup-plugin-vue": "^5.1.5",
    "vue": "^2.6.11",
    "vue-template-compiler": "^2.6.11"
  }
}

src/entry.js will have:

// Import vue component
import component from './InlineInput.vue';

// install function executed by Vue.use()
function install(Vue) {
  if (install.installed) return;

  install.installed = true;
  Vue.component('InlineInput', component);
}

// Create module definition for Vue.use()
const plugin = { install };

// To auto-install when vue is found
/* global window global */
let GlobalVue = null;

if (typeof window !== 'undefined') {
  GlobalVue = window.Vue;
} else if (typeof global !== 'undefined') {
  GlobalVue = global.Vue;
}

if (GlobalVue) {
  GlobalVue.use(plugin);
}

// Inject install function into component - allows component
// to be registered via Vue.use() as well as Vue.component()
component.install = install;

// Export component by default
export default component;

build/rollup.config.js will contain:

// rollup.config.js
import vue from 'rollup-plugin-vue';
import buble from '@rollup/plugin-buble';
import commonjs from '@rollup/plugin-commonjs';
import replace from '@rollup/plugin-replace';
import { terser } from 'rollup-plugin-terser';
import minimist from 'minimist';

const argv = minimist(process.argv.slice(2));

const baseConfig = {
  input: 'src/entry.js',
  plugins: {
    preVue: [
      replace({
        'process.env.NODE_ENV': JSON.stringify('production'),
      }),
      commonjs(),
    ],
    vue: {
      css: true,
      template: {
        isProduction: true,
      },
    },
    postVue: [
      buble(),
    ],
  },
};

// UMD/IIFE shared settings: externals and output.globals
// Refer to https://rollupjs.org/guide/en#output-globals for details
const external = [];
const globals = {
  // Provide global variable names to replace your external imports
  // eg. jquery: '$'
};

// Customize configs for individual targets
const buildFormats = [];
if (!argv.format || argv.format === 'es') {
  const esConfig = {
    ...baseConfig,
    output: {
      file: 'dist/vue-inline-input.esm.js',
      format: 'esm',
      exports: 'named',
    },
    plugins: [
      ...baseConfig.plugins.preVue,
      vue(baseConfig.plugins.vue),
      ...baseConfig.plugins.postVue,
      terser({
        output: {
          ecma: 6,
        },
      }),
    ],
  };
  buildFormats.push(esConfig);
}

if (!argv.format || argv.format === 'cjs') {
  const umdConfig = {
    ...baseConfig,
    external,
    output: {
      compact: true,
      file: 'dist/vue-inline-input.ssr.js',
      format: 'cjs',
      name: 'vue-inline-input',
      exports: 'named',
      globals,
    },
    plugins: [
      ...baseConfig.plugins.preVue,
      vue({
        ...baseConfig.plugins.vue,
        template: {
          ...baseConfig.plugins.vue.template,
          optimizeSSR: true,
        },
      }),
      ...baseConfig.plugins.postVue,
    ],
  };
  buildFormats.push(umdConfig);
}

if (!argv.format || argv.format === 'iife') {
  const unpkgConfig = {
    ...baseConfig,
    external,
    output: {
      compact: true,
      file: 'dist/vue-inline-input.min.js',
      format: 'iife',
      name: 'VueInlineInput',
      exports: 'named',
      globals,
    },
    plugins: [
      ...baseConfig.plugins.preVue,
      vue(baseConfig.plugins.vue),
      ...baseConfig.plugins.postVue,
      terser({
        output: {
          ecma: 5,
        },
      }),
    ],
  };
  buildFormats.push(unpkgConfig);
}

// Export config
export default buildFormats;

Now we're done with housekeeping, it's time to dive into the actual component. This is a good place to create a commit.

The default state of an inline editable component is to display a static element. When the component is clicked, it transforms into an input field that accepts user input. When focus goes away from the element, it toggles back to a static element.

Our component will handle text, number, select and textarea inputs.

We'll leave styling up to users. At the least, you should show a pointer cursor when the mouse hovers over the static element.

Number and text input

<template>
  <input 
    v-if="editing && (isText || isNumber)"
    :class="inputClasses"
    :type="type" 
    :value="value" 
    :placeholder="placeholder"
    ref="inputEl"
    v-on:keyup.enter="handleEnter"
    @input="handleInput"
    @blur="handleBlur">

  <span :class="labelClasses" v-else @click="toggle()">
    {{ label }}
  </span>
</template>
<script>
export default {
  name: 'InlineInput',
  props: {
    value: {
      type: [Number, String],
      default: () => '' 
    },
    type: {
      type: String,
      default: () => 'text'
    },
    emitOnBlur: {
      type: Boolean,
      default: () => false
    },
    placeholder: {
      type: String,
      default: () => ''
    },
    labelClasses: {
      type: String,
      default: () => ''
    },
    inputClasses: {
      type: String,
      default: () => ''
    }
  },
  data() {
    return {
      editing: false
    };
  },
  computed: {
    isText() {
      return this.type === 'text';
    },
    isNumber() {
      return this.type === 'number';
    },
    label() {
      if (this.isNumber) return this.value === '' ? this.placeholder : this.value;
      if (this.isText) return this.value ? this.value : this.placeholder;
    }
  },
  methods: {
    toggle() {
      this.editing = !this.editing;
      
      if (this.editing) {
        this.$nextTick(() => {
          this.$refs.inputEl.focus();
        });
      }
    },
    handleEnter() {
      this.$refs.inputEl.blur();
    },
    handleBlur() {
      this.toggle();
      this.emitValue();
    },
    handleInput() {
      if (!this.emitOnBlur) this.emitValue();
    },
    handleChange() {
      this.emitValue();
    },
    emitValue() {
      this.$emit('input', this.isNumber ? parseFloat(this.$refs.inputEl.value) : this.$refs.inputEl.value);
    }
  }
};
</script>

Our default state is a static element. We'll use a span as our static element. When the span is clicked, we hide the span and show our input element (via the @click handler on the span).

When the span is clicked, toggle() is called which does two things. It hides the span by setting editing to true and it places the focus on our input so it can accept user input without the user needed to click on the input again. That's one of the reasons we're holding a ref to the input via inputEl; the other reasons are to read its value directly (which is simpler than using v-model in this case) and take focus away from the input.

When focus leaves the input, we handle the blur event fired via handleBlur(). This function calls toggle() to hide the input and show the span and calls emitValue()to pass the input value back to the parent component.

We also want to toggle the component when the user presses the Enter key. We handle that via handleEnter() which just takes away focus from the input element which will cause handleBlur() to be called.

When the user changes the input value, we call handleInput(). It checks if the parent component wants us to only pass the input values after the user is done ( i.e. emitOnBlur is true) rather than in real-time (which is the default). If real-time updates are needed, we send the value to the parent component via emitValue().

handleChange() is for select inputs. It sends the selected value when the user makes a new selection.

emitValue() sends updates to the parent component by firing an input element. This enables the parent component to use the v-model directive to listen for updates. If type is set to number, we convert the value to a number before passing it along.

The inputClasses and labelClasses props are to give the user full control over styling as we're not going to do any styling.

Our label computed prop controls what is shown when the static element is shown. For number inputs, we only show placeholder when nothing has been input; otherwise we show the user input (this ensures that 0 is shown, for example). For text inputs, we show placeholder if value is empty.

That's all that's needed for text and number input. We'll go ahead and create a commit here.

Textarea

<template>
  <!-- -->

  <textarea 
    v-else-if="editing && isTextArea"
    :class="inputClasses"
    :value="value"
    :placeholder="placeholder"
    ref="inputEl"
    :rows="rows"
    :cols="cols"
    @input="handleInput"
    @blur="handleBlur">
  </textarea>

  <!-- -->
</template>
<script>
export default {
  // ...
  props: {
    // ...
    rows: {
      type: Number,
      default: () => 2
    },
    cols: {
      type: Number,
      default: () => 20
    }
  },
  // ...
  computed: {
    // ...
    isTextArea() {
      return this.type === 'textarea';
    },
    label() {
      // ...
      if (this.isText || this.isTextArea) return this.value ? this.value : this.placeholder;
    }
  },
  methods: {
  	// ...
  }
};
</script>

In textareas, Enter is a normal input and it would be bad UX to toggle the component when Enter is pressed. That's the reason we handled Enter separately from other inputs. This way, we only have to not add the handler to the textarea, instead of modifying handleInput() to differentiate Enters from other input and textareas for number and text. This would make our function more complex and more error-prone.

The only addition is the rows and cols props to give the parent component control over sizing. They are initialized to their HTML default values if the parent component does not supply values.

That's all that's needed to support textareas. Let's make a commit here.

Select

<template>
  <!-- -->

  <select 
    v-else-if="editing && isSelect"
    :class="inputClasses"
    :value="value"
    ref="inputEl" 
    @change="handleChange"
    @blur="handleBlur">
    <option v-if="placeholder" disabled value>{{ placeholder }}</option>
    <option 
      :key="i"
      v-for="(o, i) in options" 
      :value="o.value">
      {{ o.label }}
    </option>
  </select>

  <span v-else :class="labelClasses" @click="toggle()">
    {{ label }}
    <slot name="selectCaret">
      <span v-if="isSelect">&#9660;</span>
    </slot>
  </span>
</template>
<script>
export default {
  name: 'InlineInput',
  props: {
    // ...
    options: {
      type: Array,
      default: () => []
    }
  },
  // ...
  computed: {
    // ...
    isSelect() {
      return this.type === 'select';
    },
    label() {
      // ...
      // Select
      return this.options
        .reduce((currLabel, { label, value }) => this.value === value ? label : currLabel, this.value);
    }
  },
  methods: {
  	// ...
  }
};
</script>

For selects we need an array of options which is supplied through the options prop, which is an array of objects that have label and value keys.

In the span element we show a caret to indicate that it's a select. We added the caret in a named slot to enable the user change the default.

In our label computed prop, for selects we display the label of the currently selected value.

With that, we have added support for select inputs. As we've done previously, we make a commit here.

Publishing to NPM

Before we publish, we need to fill our README with helpful text and build the component.

To build the component, run:

$ npm run build

Our README content:

# vue-inline-input

> An inline editable input component for Vue.

Displays as text and becomes editable by clicking or tapping.

## Installation

```js
npm install vue-inline-input
```

### Browser

```html
<script type="text/javascript" src="https://unpkg.com/vue"></script>
<script type="text/javascript" src="https://unpkg.com/vue-inline-input"></script>
<script type="text/javascript">
  Vue.use(InlineInput);
</script>
```

### Module

```js
import InlineInput from 'vue-inline-input';
```

## Usage

Once installed, it can be used in a template as:

```html
<inline-input v-model="amount" />
```

See the props table below for the available options.

### Props

| Property | Type | Description | Default |
|:--|:--|:--|:--|
| type | string | The input type. Could be text, number, textarea or select | text |
| emit-on-blur | boolean | Save input changes only on blur | false |
| placeholder | string | Text to be shown as a placeholder while there is no input |  empty string |
| label-classes | string | CSS classes for the label element | empty string |
| input-classes | string | CSS classes for the input element | empty string |
| rows | integer | Textarea rows | 2 |
| cols | integer | Textarea columns | 20 |
| options | array | Provides the options for selects. Each object should have the format `{label: x, value: x}` | [] |

## Contribution
Install dependencies
```
npm install
```
When you're done, build the dist files
```
npm run build
```

## License

[MIT](http://opensource.org/licenses/MIT)

Commit the changes.

To publish, I first login to my NPM account with:

$ npm login

Then, from the root directory I run:

$ npm publish

After successfully publishing, we can use our component in Node.js projects by installing it with:

$ npm install vue-inline-input

or in the browser using https://unpkg.com/vue-inline-input.

The GitHub repository can be found here.