alpine.js table edit-in-place functionality - javascript

I'm trying to make a table column editable inline using Alpine.js. The idea is to have an "edit in place" functionality so when a row is double-clicked allows for the content to be editable. The issue I'm having is when a cell is clicked it activates all rows.
The ideal behavior is only the clicked row should be editable, all others should remain uneditable.
I have a preview of the issue here, https://codepen.io/ezeagwulae/pen/ZEKeYGQ
<div x-data="data()" class="p-4">
<div class="uppercase font-bold">shopping items</div>
<template x-for="item in items">
<div>
<a #click.prevent #dblclick="toggleEditingState" x-show="!isEditing" x-text="item.item" class="select-none cursor-pointer underline font-lg text-blue-500"></a>
<input type="text" x-model="text" x-show="isEditing" #click.away="toggleEditingState" #keydown.enter="disableEditing" #keydown.window.escape="disableEditing" class="bg-white focus:outline-none focus:shadow-outline border border-gray-300 rounded-lg py-2 px-4 appearance-none leading-normal w-128" x-ref="input">
</div>
</template>
</div>

In your JS file make sure to get the double-clicked input field with e.target.
In your HTML x-model should be set to item.item.
Here's a working example.
HTML
<div x-data="data()" class="p-4">
<div class="uppercase font-bold">shopping items</div>
<template x-for="item in items">
<div>
<a #click.prevent #dblclick="toggleEditingState" x-show="!isEditing" x-text="item.item" class="select-none cursor-pointer underline font-lg text-blue-500"></a>
<input type="text" x-model="item.item" x-show="isEditing" #click.away="toggleEditingState" #keydown.enter="disableEditing" #keydown.window.escape="disableEditing" class="bg-white focus:outline-none focus:shadow-outline border border-gray-300 rounded-lg py-2 px-4 appearance-none leading-normal w-128" x-ref="input">
</div>
</template>
</div>
JS
function data() {
return {
text: "Double click to edit",
isEditing: false,
toggleEditingState(e) {
const el = e.target
this.isEditing = !this.isEditing;
el.focus()
},
disableEditing() {
this.isEditing = false;
},
items: [
{ id: 1, item: "apple" },
{ id: 2, item: "eggs" },
{ id: 3, item: "milk" }
]
};
}
Any suggestions to make only the clicked row editable and not all rows? For instance, if "eggs" the input field should be shown for this row and the other rows should remain as is
For example like this:
<link href="https://unpkg.com/tailwindcss#^2/dist/tailwind.min.css" rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine#v2.x.x/dist/alpine.js"></script>
<div
x-data="{
items: [
{ id: 1, item: 'apple', edit: false },
{ id: 2, item: 'eggs', edit: false },
{ id: 3, item: 'milk', edit: false },
]
}"
class="p-4"
>
<div class="uppercase font-bold">shopping items</div>
<template x-for="(item, index) in items">
<div>
<a
#click.prevent
#dblclick="
item.edit = true;
$nextTick(() => $refs[item.id].focus());
"
#click.away="item.edit = false"
x-show="!item.edit"
x-text="item.item"
class="
select-none
cursor-pointer
underline
font-lg
text-blue-500
"
></a>
<input
type="text"
x-model="item.item"
x-show="item.edit"
#click.away="item.edit = false"
#keydown.enter="item.edit = false"
#keydown.window.escape="item.edit = false"
class="
bg-white
focus:outline-none focus:shadow-outline
border border-gray-300
rounded-lg
py-2
px-4
appearance-none
leading-normal
w-128
"
:x-ref="item.id"
/>
</div>
</template>
</div>

Related

VueJS - Click event is not triggered as parent event prevaults

I am building a search input that fetchs data from my API and lists it in a dropdown list.
Here is the behavior I want my component to have:
If I start typing and my API founds data, it opens the dropdown menu and lists it.
If I click on one of the elements from the list, it is set as 'activeItem' and the dropdown list closes
Else, I can click out of the component (input and dropdown list) and the dropdown list closes
Else, no dropdown list appears and my input works like a regular text input
My issue has to do with Event Bubbling.
My list items (from API) have a #click input that set the clicked element as the 'activeItem'.
My input has both #focusin and #focusout events, that allow me to display or hide the dropdown list.
I can't click the elements in the dropdown list as the #focusout event from the input is being triggered first and closes the list.
import ...
export default {
components: {
...
},
props: {
...
},
data() {
return {
results: [],
activeItem: null,
isFocus: false,
}
},
watch: {
modelValue: _.debounce(function (newSearchText) {
... API Call
}, 350)
},
computed: {
computedLabel() {
return this.required ? this.label + '<span class="text-primary-600 font-bold ml-1">*</span>' : this.label;
},
value: {
get() {
return this.modelValue
},
set(value) {
this.$emit('update:modelValue', value)
}
}
},
methods: {
setActiveItem(item) {
this.activeItem = item;
this.$emit('selectItem', this.activeItem);
},
resetActiveItem() {
this.activeItem = null;
this.isFocus = false;
this.results = [];
this.$emit('selectItem', null);
},
},
emits: [
'selectItem',
'update:modelValue',
],
}
</script>
<template>
<div class="relative">
<label
v-if="label.length"
class="block text-tiny font-bold tracking-wide font-medium text-black/75 mb-1 uppercase"
v-html="computedLabel"
></label>
<div :class="widthCssClass">
<div class="relative" v-if="!activeItem">
<div class="flex items-center text-secondary-800">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3.5 w-3.5 ml-4 absolute"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<!-- The input that triggers the API call -->
<input
class="text-black py-2.5 pr-3.5 pl-10 text-black focus:ring-primary-800 focus:border-primary-800 block w-full rounded sm:text-sm border-gray-300"
placeholder="Search for anything..."
type="text"
#input="$emit('update:modelValue', $event.target.value)"
#focusin="isFocus = true"
#focusout="isFocus = false"
>
</div>
<!-- The Dropdown list -->
<Card
class="rounded-t-none shadow-2xl absolute w-full z-10 mt-1 overflow-y-auto max-h-48 px-0 py-0"
v-if="isFocus && results.length"
>
<div class="flow-root">
<ul role="list" class="divide-y divide-gray-200">
<!-- API results are displayed here -->
<li
v-for="(result, index) in results"
:key="index"
#click="setActiveItem(result)" <!-- The event I can't trigger -->
>
<div class="flex items-center space-x-4 cursor-pointer px-4 py-3">
<div class="flex-shrink-0">
<img
class="h-8 w-8 rounded-md ring-2 ring-lighter shadow-lg"
:src="result.image ?? this.$page.props.page.defaultImage.url"
:alt="result.title"
/>
</div>
<div class="min-w-0 flex-1">
<p
class="truncate text-sm font-medium text-black"
:class="{
'text-primary-900 font-bold': result.id === activeItem?.id
}"
>
{{ result.title }}
</p>
<p class="truncate text-sm text-black/75">
{{ result.description }}
</p>
</div>
<div v-if="result.action">
<Link
:href="result.action?.url"
class="inline-flex items-center rounded-full border border-gray-300 bg-white px-2.5 py-0.5 text-sm font-medium leading-5 text-black/75 shadow-sm hover:bg-primary-50"
target="_blank"
>
{{ result.action?.text }}
</Link>
</div>
</div>
</li>
</ul>
</div>
</Card>
</div>
<!-- Display the active element, can be ignored for this example -->
<div v-else>
<article class="bg-primary-50 border-2 border-primary-800 rounded-md">
<div class="flex items-center space-x-4 px-4 py-3">
<div class="flex-shrink-0">
<img
class="h-8 w-8 rounded-md ring-2 ring-lighter shadow-lg"
:src="activeItem.image ?? this.$page.props.page.defaultImage.url"
:alt="activeItem.title"
/>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-black font-bold">
{{ activeItem.title }}
</p>
<p class="truncate text-sm text-black/75 whitespace-pre-wrap">
{{ activeItem.description }}
</p>
</div>
<div class="flex">
<AppButton #click.stop="resetActiveItem();" #focusout.stop>
<svg
class="w-5 h-5 text-primary-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</AppButton>
</div>
</div>
</article>
</div>
</div>
</div>
</template>
Here is a look at the input:
With API results (can't click the elements):
When no data is found:
I tried:
handleFocusOut(e) {
console.log(e.relatedTarget, e.target, e.currentTarget)
// No matter where I click:
// e.relatedTarget = null
// e.target = <input id="search" class="...
// e.currentTarget = <input id="search" class="...
}
...
<input
id="search"
class="..."
placeholder="Search for anything..."
type="text"
#input="$emit('update:modelValue', $event.target.value)"
#focusin="isFocus = true"
#focusout="handleFocusOut($event)"
>
The solution:
relatedTarget will be null if the element you click on is not
focusable. by adding the tabindex attribute it should make the element
focusable and allow it to be set as relatedTarget. if you actually
happen to be clicking on some container or overlay element make sure
the element being clicked on has that tabindex="0" added to it so you
can maintain isFocus = true
Thanks to #yoduh for the solution
The root issue looks to be how the dropdown list is being removed from the DOM as soon as the input loses focus because of the v-if on it.
<Card
v-if="isFocus && results.length"
>
This is ok to have, but you'll need to work around it by coming up with a solution that keeps isFocus true whether the focus is on the input or the dropdown. I would suggest your input's #focusout to execute a method that only sets isFocus = false if the focus event's relatedTarget is not any of the dropdown items (can be determined via classname or other attribute). One roadblock to implementing this is that some elements aren't natively focusable, like <li> items, so they won't be set as the relatedTarget, but you can make them focusable by adding the tabindex attribute. Putting it all together should look something like this:
<input
type="text"
#input="$emit('update:modelValue', $event.target.value)"
#focusin="isFocus = true"
#focusout="loseFocus($event)"
/>
...
<li
v-for="(result, index) in results"
:key="index"
class="listResult"
tabindex="0"
#click="setActiveItem(result)"
>
loseFocus(event) {
if (event.relatedTarget?.className !== 'listResult') {
this.isFocus = false;
}
}
setActiveItem(item) {
this.activeItem = item;
this.isFocus = false;
this.$emit('selectItem', this.activeItem);
}

How I can click on the element array?

I have two arrays, one - full elements(filterList), two - empty(addList). When I click on the first element (full array) it's added to second array (empty array). How I can click on the element (title:'DATA') ?
<ul class="flex">
<li class="text-white text-xs uppercase font-normal not-italic px-2 py-1.75 mr-3 border border-gray-900 border-solid rounded-xs transition duration-500 hover:cursor-pointer hover:border-purple hover:bg-black-700"
v-for="(item, index) of addList"
:item="item"
:key="index"
#click="testClick()"
>
<span class="flex items-center">{{item.title}} <IconCheckMark class="stroke-white ml-2.25"/></span>
</li>
</ul>
<ul class="border border-purple rounded-md p-2.5 inline-block min-w-35.25 bg-black-900 absolute z-110 max-h-64 overflow-y-auto overflow-x-hidden">
<li
v-for="(item, index) of filterList"
:item="item"
:key="index"
class="text-xs text-white uppercase py-1.5 px-2.5 hover:bg-black-700 hover:rounded-xs hover:cursor-pointer transition-all ease-in" #click="addFiltres(item)"
>
<span>{{item.title}}</span>
</li>
</ul>
addFiltres(item){
if (this.addList.indexOf(this.filterList) == this.addList.lastIndexOf(this.filterList)) {
this.addList.push(item)
}
this.addList = \[...new Set(this.addList)\]
},
testClick() {
},
filterList: \[
{title: 'DATE', value: 'date'},
{title: 'TIME', value: 'time'},
{title: 'COLOR', value: 'color'},
],
addList:[]
I try to solve this problem: 1) indexOf () 2) find() 3) includes(). Not work.

Vuejs & Tailwind CSS dynamically adding two input fields with one button click

I'm using vue 3 and tailwind css to build an interface and I'm trying to add two different input fields, after clicking the add button, and remove the two fields after clicking the remove button. Something like this Two dynamic input fields with one button click
Currently, I have it working, but it is showing a wrong format. When I click the add button, it shows two input keys in the first row and two input values in the second row.
Wrong Input format.
When I click on the add button, I want it to show one input key and one input value in the first row, and the same in the next row.
This is the form
<div v-show="type == 'dropdown'" class="grid gap-4 md:grid-cols-5 items-center">
<div v-for="(value, index) in keys" :key="index" class="col-span-2">
<input v-model="value.key" type="text" class="block p-2 w-full text-gray-700 bg-gray-50 rounded-sm border border-gray-300" placeholder="Input key" />
</div>
<div v-for="(value, index) in input_values" :key="index" class="col-span-2 row-span-1">
<input v-model="value.input_value" type="text" class="block p-2 w-full text-gray-700 bg-gray-50 rounded-sm border border-gray-300" placeholder="Input value" />
</div>
<div class="col-span-1 row-span-1">
<span><fa icon="square-plus" class="w-6 h-6 text-green-500 cursor-pointer" #click="add" /></span>
<fa v-show="index != 0" icon="square-minus" class="w-6 h-6 px-2 text-red-500 cursor-pointer" #click="remove(index)" />
</div>
</div>
This the script section
export default {
name: "App",
data() {
return {
keys: [{ key: "" }],
input_values: [{ input_value: "" }],
}
},
add() {
this.keys.push({ key: "" })
this.input_values.push({ input_value: "" })
},
remove(index) {
this.keys.splice(index, 1)
this.input_values.splice(index, 1)
},
}

Vue: Return select value from component back to parent component

I can't figure this out. I have got a component StockSearch.vue, within this component I have got another component called StockSearchSelect.vue. Code is below.
I want to change the selected value within the makes object within StockSearch when the selected option is changed within the StockSerchSelect component. How do I do this?
StockSearch.vue
<template>
<div class="flex flex-col lg:flex-row">
<search-select title="Make" :options="data.makes"></search-select>
<search-select title="Model" :options="data.makes"></search-select>
<search-select title="Variant" :options="data.makes"></search-select>
<search-select title="Trim" :options="data.makes"></search-select>
<search-select title="Bodystyle" :options="data.makes"></search-select>
<search-select title="Transmission" :options="data.makes"></search-select>
<search-select title="Doors" :options="data.makes"></search-select>
</div>
</template>
<script>
import SearchSelect from './StockSearchSelect';
export default {
components: {
SearchSelect
},
data: function() {
return {
data: {
makes: {
options: [
{ code: 1, display: 'Audi' },
{ code: 2, display: 'BMW' },
{ code: 3, display: 'Chevrolet' },
{ code: 4, display: 'Mercedes Benz' },
{ code: 5, display: 'Suzuki' },
{ code: 6, display: 'Volvo' },
{ code: 7, display: 'Lamborghini' },
{ code: 8, display: 'Citron' },
{ code: 9, display: 'Jeep' },
],
selected: null
}
}
}
},
watch: {
data: {
deep: true,
handler: function(data) {
console.log(data);
}
}
}
}
</script>
StockSearchSelect.vue
<template>
<div class="w-full p-2">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-state">{{ title }}</label>
<div class="relative">
<select class="block appearance-none w-full bg-gray-200 border border-gray-200 text-gray-700 py-3 px-4 pr-8 rounded leading-tight focus:outline-none focus:bg-white focus:border-gray-500" id="grid-state" v-model="selected">
<option value="">Any {{ title }}</option>
<option v-for="(value, index) in data.options" :key="index" :value="value.code">{{ value.display }}</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"/></svg>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
title: String,
options: Array,
selected: Int
},
data: function() {
return {
selected: null
}
},
watch: {
selected: function(value) {
}
}
}
</script>
If you only want to update the makes object when an option is changed, all you need to do is $emit an event when the value changes and then listen for the event in the parent component. You should probably read the props and custom events documentation.
You could change the below select to include #input='$emit("selected", $event.target.value)'
<select class="block appearance-none w-full bg-gray-200 border border-gray-200 text-gray-700 py-3 px-4 pr-8 rounded leading-tight focus:outline-none focus:bg-white focus:border-gray-500" id="grid-state" v-model="selected" #input='$emit("selected", $event.target.value)'>
<option value="">Any {{ title }}</option>
<option v-for="(value, index) in data.options" :key="index" :value="value.code">{{ value.display }}</option>
</select>
and then add #selected="data.makes.selected = $event" to the below component.
<search-select title="Doors" :options="data.makes" #selected="data.makes.selected = $event"></search-select>
I've also added a working snippet below in case that helps.
Vue.component("my-select", {
template: "<select #input='$emit(`selected`, $event.target.value)'><option selected>Please Select</option><option value='1'>1</option><option value='2'>2</option></select>"
});
new Vue({
el: "#app",
data: () => {
return {
selectedValue: null
}
}
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<div id="app">
<div>
<my-select #selected="selectedValue = $event"></my-select>
{{selectedValue}}
</div>
</div>

Building an input form type file in VueJS Application

I'm trying to build a component in VueJS with input field for file type. Here is my component code:
<template>
<div class="flex-col justify-start w-full">
<div class="mt-2 block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2">{{ label }}</div>
<input
class="block appearance-none w-full bg-gray-200 border border-gray-200 text-gray-700 py-3 px-4 pr-8 rounded leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
:class="errorDisplay ? 'border-red-500 focus:bg-white focus:border-red-500': ''"
type="file"
:value="value" #input="emitEvent($event)"
ref="input_file"
>
<span v-if="hint" class="text-xs text-gray-400 font-medium">{{ hint }}</span>
<span v-if="errorDisplay" class="text-xs text-pink-600 font-medium">{{ errorDisplay }}</span>
</div>
</template>
<script>
export default {
name: "InputFile",
props: {
label: String,
hint: {
type: String,
default: () => ''
},
error: {
type: Array,
default: () => []
},
placeholder: String,
value: Object,
},
methods: {
emitEvent(event) {
var reader = new FileReader();
reader.readAsDataURL(event.target.files[0]);
reader.onload = () => {
const docs = {
name: event.target.files[0].name,
size: event.target.files[0].size,
lastModifiedDate: event.target.files[0].lastModifiedDate,
base64: reader.result
};
console.log(docs);
this.$emit('input', docs)
};
}
},
computed: {
errorDisplay() {
if(this.error.length)
return this.error.join(', ');
else
return '';
}
}
}
</script>
And I'm calling my component as below:
<template>
<div class="flex items-center justify-start">
<div class="w-1/2 m-2 rounded-lg shadow-lg border b-gray-400 rounded flex flex-col justify-start items-start p-6 bg-white">
<div class="border-b -m-2 mb-3 px-6 py-2 flex-none w-full justify-start text-gray-700 font-semibold"> Base Controls </div>
<input-file
label="Upload file"
v-model="upload_file"
:error="errors['upload_file']"
>
</input-file>
<div class="mt-4 text-center">
<button #click="submit()" class="inline-block px-4 py-2 rounded-lg shadow-md bg-teal-500 hover:bg-teal-400 focus:outline-none focus:shadow-outline text-white text-sm tracking-wider font-semibold">Submit</button>
</div>
</div>
</div>
</template>
<script>
import InputFile from "../Elements/Forms/Inputs/File";
export default {
name: "Forms",
components: {
InputFile,
},
data() {
return {
upload_file: '',
errors: {},
}
},
methods: {
submit() {
//Submit code...
}
}
}
</script>
But I'm always getting an error:
Error in nextTick: "InvalidStateError: Failed to set the 'value' property on 'HTMLInputElement': This input element accepts a filename, which may only be programmatically set to the empty string."
I can see my event is getting emitted and upload_file has desired value set. To overcome this I made upload_file to object but this results in error and the component is also not shown. How can I fix this?
I believe the issue comes from trying to assign to the element's 'value' property (by binding it to prop.value)
When you're dealing with file-type elements, you can't write to the value property like you can with other types.
In your custom component's template, delete the binding, :value="value"
and in its script either:
delete the prop value: Object or,
if you need to assign the value prop for v-model compatibility, assign it to File. eg: value: File
note: This will work, but you'll get a Vue warning: 'type check failed' for an invalid prop when the component is called without a supplied file.
ie...
<template>
<div class="flex-col justify-start w-full">
<div class="mt-2 block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2">{{ label }}</div>
<input
class="block appearance-none w-full bg-gray-200 border border-gray-200 text-gray-700 py-3 px-4 pr-8 rounded leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
:class="errorDisplay ? 'border-red-500 focus:bg-white focus:border-red-500': ''"
type="file"
#input="emitEvent($event)"
ref="input_file"
>
<span v-if="hint" class="text-xs text-gray-400 font-medium">{{ hint }}</span>
<span v-if="errorDisplay" class="text-xs text-pink-600 font-medium">{{ errorDisplay }}</span>
</div>
</template>
<script>
export default {
name: "InputFile",
props: {
label: String,
hint: {
type: String,
default: () => ''
},
error: {
type: Array,
default: () => []
},
placeholder: String,
value: File,
},
methods: {
emitEvent(event) {
var reader = new FileReader();
reader.readAsDataURL(event.target.files[0]);
reader.onload = () => {
const docs = {
name: event.target.files[0].name,
size: event.target.files[0].size,
lastModifiedDate: event.target.files[0].lastModifiedDate,
base64: reader.result
};
console.log(docs);
this.$emit('input', docs)
};
}
},
computed: {
errorDisplay() {
if(this.error.length)
return this.error.join(', ');
else
return '';
}
}
}
</script>
should be ok.

Categories

Resources