Kalnode
Kalnode

Reputation: 11364

Dynamic 2-way binding with Vue3+Pinia

Dynamic binding across a Vue app, using Pinia

I'm probably overlooking something fundamental with Vue, but...

Given dynamic components, each getting a generated ref and id, how can a reference to the component be dynamically passed via Pinia (or any other global means), such that a completely unrelated component elsewhere in the app can have dynamic binding (ideally 2-way) with it?

Indeed with pre-defined state entries in Pinia, you can easily have global binding; this is what Pinia does. In this app there are already many things bound to Pinia states. These are known entities and have explicit Pinia store entries. However dynamic components are not known therefore have no explicit store entries... and there's no desire to manage, let's say, 100's (exaggeration) of store entries.

Use case

A kiosk app on custom hardware with no readily available keyboard (hardware or OS). In the app, I have a virtual keyboard component that appears in a sliding drawer component. Across the app are various input fields presented dynamically. As the user focuses (taps) on input fields, I need the field bound (ideally 2-way binding) to the app's virtual keyboard component.

Current solution (works partially; not ideal)

We have the ability to edit input fields with the virtual keyboard, but it's via DOM manipulation, and there's no proper binding...

On input field focus event, pass reference to input field into Pinia state store.activeField (we don't have to do this, but it's convenient) and open the app's virtual keyboard drawer.

An input component instance

The input components are a little more complicated but it roughly boils down to this:

<input v-model="value" :id="id" :ref="id" @focus="store.openKeyboard($event.id)" />

Store (Pinia)

openKeyboard(id:string) {
  this.activeInput = document.getElementById(id) as HTMLInputElement
  this.showKeyboardDrawer = true
}

Keyboard component

In the virtual-keyboard, on input we update the value of the focused input field DOM element (via store.activeField). This is how we edit the input field; there's no binding. After editing the DOM value, the Vue instance wrapping it which feeds the v-model, is out of sync, so we trigger an input event (dispatchEvent) so Vue knows the input field value has changed.

const OnInputChange = (event:any) => {
  store.activeInput.value = event
  store.activeInput.dispatchEvent(new Event('input'))
}

This provides editing-ability of the focus input field, but still not true binding and involves DOM work.

Other techniques

Looking for other techniques...

Ideally:

1 - Pass a Vue ref? Typically ref's are local to SFC's, yes? Or, is there a way to globally access a ref? How do you ensure unique ref names?

2 - Broadcast keyboard inputs and listen for them in each instance of an input field, then make changes accordingly (ie check if field is focused). The broadcast could be via Pinia or event bus. This doesn't solve the binding need, but at least it avoids DOM manipulation. Downside is we have a bunch of active watchers (one per field).

3 - Something else?

Any feedback appreciated.

Upvotes: 0

Views: 499

Answers (1)

Alexander Nenashev
Alexander Nenashev

Reputation: 23602

You could create a wrapper component (though implement your our binding rules, I'm using the focusin event):

VUE SFC PLAYGROUND

<script>

import {ref} from 'vue';
export const keyboardText = ref('');
let off;
const activeInput = ref();
watch(activeInput, (input, old) => {
  old?.exposed.setActive(false);
  input.exposed.setActive(true);
});

</script>
<script setup>

import {useSlots,watch, withDirectives, vModelText, getCurrentInstance, computed} from 'vue';

const model = defineModel();
const slots = useSlots();
const self = getCurrentInstance();
const active = ref(false);

defineExpose({
  setActive(val){
    active.value = val;
  }
});

watch(model, val => active.value && (keyboardText.value = val));

const vClass = (el, {value})=>{
  for(const k in value){
    el.classList.toggle(k, !!value[k].value);
  }
}
const render = () => {

  const out = slots.default();
  out.forEach(vnode => Object.assign(vnode.props ??= {}, {
    onFocusin(){
      off?.();
      keyboardText.value = model.value;
      off = watch(keyboardText, text => model.value = text);
      activeInput.value = self;
    },
    modelValue: model.value,
    'onUpdate:modelValue': text => model.value = text
  }));

  return out.map(vnode => withDirectives(vnode, [[vClass, {active}]]));
};


</script>

<template>
  <render :class="{active}"/>
</template>

<style>

  input.active{
    background: #ffb;
  }

</style>

Upvotes: 0

Related Questions