Skip to content

Usage with Vue

The library is framework-agnostic, but it fits Vue cleanly: render the markup in your template, then create the manager in onMounted and tear it down in onUnmounted. Scope the scan to the component's own element so it never touches the rest of the page.

This page is the proof

The live demo on the SpinBox page is a Vue component using exactly the pattern below — see SpinBoxDemo.vue.

In a single component

vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { SpinBox } from '@popovandrii/ui-elements'
import '@popovandrii/ui-elements/style.css'

const root = ref<HTMLElement>()
let spin: SpinBox | null = null

function onChange(e: Event) {
  const { id, value } = (e as CustomEvent<{ id: string; value: string }>).detail
  console.log(id, value)
}

onMounted(() => {
  // `root` limits the scan to THIS component's subtree.
  spin = new SpinBox({}, false, { root: root.value! })
  root.value!.addEventListener('ui-spinbox-change', onChange)
})

onUnmounted(() => {
  root.value?.removeEventListener('ui-spinbox-change', onChange)
  spin?.destroy()
})
</script>

<template>
  <div ref="root">
    <div class="UIsp primary" data-min="0" data-max="100"
         role="spinbutton" tabindex="0" aria-label="Volume">
      <button class="UIsp__btn" type="button" aria-label="Decrease">−</button>
      <input class="UIsp__input" id="volume" type="text" value="50"
             aria-label="Current value" inputmode="decimal" />
      <button class="UIsp__btn" type="button" aria-label="Increase">+</button>
    </div>
  </div>
</template>

For a v-for that grows and shrinks, the simplest path is to let the manager watch the DOM itself. Pass observe: true and it attaches a (debounced) MutationObserver to your root — every time Vue adds or removes a row, it re-scans automatically. No watch, no nextTick, no manual scan():

vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { SpinBox } from '@popovandrii/ui-elements'
import '@popovandrii/ui-elements/style.css'

const root = ref<HTMLElement>()
const items = ref([{ id: 'sb-1', value: 1 }])
let spin: SpinBox | null = null

function onChange(e: Event) {
  const { id, value } = (e as CustomEvent<{ id: string; value: string }>).detail
  const row = items.value.find((i) => i.id === id)
  if (row) row.value = Number(value)
}

onMounted(() => {
  // `observe: true` → the manager re-scans on its own when the v-for changes.
  spin = new SpinBox({}, false, { root: root.value!, observe: true })
  root.value!.addEventListener('ui-spinbox-change', onChange)
})

onUnmounted(() => {
  root.value?.removeEventListener('ui-spinbox-change', onChange)
  spin?.destroy() // also disconnects the MutationObserver
})

function addRow() {
  items.value.push({ id: `sb-${items.value.length + 1}`, value: 0 })
}
</script>

<template>
  <div ref="root">
    <div
      v-for="row in items"
      :key="row.id"
      class="UIsp"
      data-min="0"
      data-max="10"
      role="spinbutton"
      tabindex="0"
      aria-label="Row value"
    >
      <button class="UIsp__btn" type="button" aria-label="Decrease">−</button>
      <input
        class="UIsp__input"
        :id="row.id"
        type="text"
        :value="row.value"
        aria-label="Current value"
        inputmode="decimal"
      />
      <button class="UIsp__btn" type="button" aria-label="Increase">+</button>
    </div>

    <button type="button" @click="addRow">Add row</button>
  </div>
</template>

destroy() on unmount disconnects the observer along with every listener — no leaks.

Disabled state

Render a SpinBox inert with the data-disabled attribute. The manager disables the input and both buttons and swallows clicks — done entirely in markup, no JS:

vue
<template>
  <div ref="root">
    <div class="UIsp" data-min="0" data-max="10" data-disabled
         role="spinbutton" tabindex="0" aria-label="Locked">
      <button class="UIsp__btn" type="button" aria-label="Decrease">−</button>
      <input class="UIsp__input" id="locked" type="text" value="3"
             aria-label="Current value" inputmode="decimal" />
      <button class="UIsp__btn" type="button" aria-label="Increase">+</button>
    </div>
  </div>
</template>

The catch for Vue: data-disabled is read only when the element is bound (at scan). Flipping it on a reactive ref afterwards does nothing on its own — you have to re-initialize. That's the next section.

Re-initializing (destroy()scan())

destroy() unbinds everything — listeners, the data-uispBound markers, the observer. A following scan() binds again from scratch, re-reading every data-* attribute as it stands now. This pair is the library's "apply my markup changes" primitive: changed data-min/data-max/data-decimals, swapped selectors, or — the common one — a reactive data-disabled.

Bind :data-disabled to a ref, and whenever it flips, rebuild after Vue has patched the DOM:

vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
import { SpinBox } from '@popovandrii/ui-elements'
import '@popovandrii/ui-elements/style.css'

const root = ref<HTMLElement>()
const locked = ref(false)
let spin: SpinBox | null = null

onMounted(() => {
  spin = new SpinBox({}, false, { root: root.value! })
})

onUnmounted(() => spin?.destroy())

// `data-disabled` is only read at bind time → re-init to apply the change.
watch(locked, async () => {
  await nextTick() // (a) let Vue write the new :data-disabled to the DOM
  spin?.destroy()  // (b) unbind
  spin?.scan()     // (c) re-bind, reading the attributes as they are now
})
</script>

<template>
  <div ref="root">
    <div
      class="UIsp"
      data-min="0"
      data-max="10"
      :data-disabled="locked ? '' : null"
      role="spinbutton"
      tabindex="0"
      aria-label="Quantity"
    >
      <button class="UIsp__btn" type="button" aria-label="Decrease">−</button>
      <input class="UIsp__input" id="qty" type="text" value="3"
             aria-label="Current value" inputmode="decimal" />
      <button class="UIsp__btn" type="button" aria-label="Increase">+</button>
    </div>

    <button type="button" @click="locked = !locked">
      {{ locked ? 'Enable' : 'Disable' }}
    </button>
  </div>
</template>

Bind to null, not false

The library checks for the presence of data-disabled (hasAttribute(...)), not its value — so the attribute must be absent when enabled. Vue removes an attribute bound to null, which is exactly right. Binding to false would render data-disabled="false" — still present, so the control would read as disabled.

The value in the input survives a destroy()scan() cycle (it lives in the DOM), so toggling locked never loses what the user typed. If you use the composable, it exposes this pair as reinit().

A reusable composable

Pull the boilerplate into a useSpinBox composable so any component can wire up a SpinBox in one line. It also exposes rescan() — the hook the manual approach below relies on:

ts
// composables/useSpinBox.ts
import { onMounted, onUnmounted, type Ref } from 'vue'
import { SpinBox } from '@popovandrii/ui-elements'

export function useSpinBox(
  root: Ref<HTMLElement | undefined>,
  onChange?: (detail: { id: string; value: string }) => void,
) {
  let spin: SpinBox | null = null

  const handler = (e: Event) =>
    onChange?.((e as CustomEvent<{ id: string; value: string }>).detail)

  onMounted(() => {
    spin = new SpinBox({}, false, { root: root.value! })
    if (onChange) root.value!.addEventListener('ui-spinbox-change', handler)
  })

  // ← This is where teardown lives. Whoever uses the composable gets
  //   destroy() for free when their component unmounts — no leaks.
  onUnmounted(() => {
    root.value?.removeEventListener('ui-spinbox-change', handler)
    spin?.destroy()
  })

  return {
    /** Programmatically set a value (clamps + syncs the buttons). */
    setValue: (el: HTMLElement, v: number) => spin?.setValue(el, v),
    /** Re-bind markup added after mount (e.g. a v-for grew). */
    rescan: () => spin?.scan(),
    /** Unbind + re-bind, re-reading every data-* (e.g. a reactive data-disabled). */
    reinit: () => {
      spin?.destroy()
      spin?.scan()
    },
  }
}
vue
<script setup lang="ts">
import { ref } from 'vue'
import { useSpinBox } from '@/composables/useSpinBox'

const root = ref<HTMLElement>()
useSpinBox(root, ({ id, value }) => console.log(id, value))
</script>

<template>
  <div ref="root"><!-- .UIsp markup here --></div>
</template>

Dynamic lists with manual scan()

Default to observe — it's less code and you can't forget it. Reach for a manual scan() only in these cases:

WhenWhy observe isn't enough
You need the new element bound synchronously (e.g. call setValue() on it right after adding it)observe is debounced ~50 ms and async — for that window the new spin box is still unbound, so an immediate call silently misses it.
A "noisy" root — unrelated components, timers or animations mutate the same subtreeobserve watches with subtree: true and wakes on every mutation, re-scanning even when no spin box changed.
A large / frequently-patched treeOne scan() per real change beats a MutationObserver firing on every batch.
No live observers wanted — jsdom tests, SSR hydrationEasier to drive the scan yourself than to rely on MutationObserver behavior.

Mechanically: the manager binds by scanning the DOM once, at onMounted. Anything Vue renders after that — a new row pushed into a v-for — was never scanned, so its buttons do nothing. The fix is to re-scan after Vue has patched the DOM.

rescan comes from the useSpinBox composable above (it just calls spin.scan()); items is your own reactive list. Here is the whole thing as one self-contained component:

vue
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useSpinBox } from '@/composables/useSpinBox'

const root = ref<HTMLElement>()
const items = ref([{ id: 'sb-1', value: 1 }])

// `rescan` re-binds .UIsp elements that exist now but weren't bound yet.
// `destroy()` is handled inside the composable on unmount — nothing to do here.
const { rescan } = useSpinBox(root, ({ id, value }) => {
  const row = items.value.find((i) => i.id === id)
  if (row) row.value = Number(value)
})

function addRow() {
  items.value.push({ id: `sb-${items.value.length + 1}`, value: 0 })
}

// When the list changes, wait for Vue to insert the new nodes, then re-scan.
watch(
  () => items.value.length,
  async () => {
    await nextTick() // (a) let Vue patch the DOM first
    rescan()         // (b) bind the newly rendered .UIsp elements
  },
)
</script>

<template>
  <div ref="root">
    <div
      v-for="row in items"
      :key="row.id"
      class="UIsp"
      data-min="0"
      data-max="10"
      role="spinbutton"
      tabindex="0"
      aria-label="Row value"
    >
      <button class="UIsp__btn" type="button" aria-label="Decrease">−</button>
      <input
        class="UIsp__input"
        :id="row.id"
        type="text"
        :value="row.value"
        aria-label="Current value"
        inputmode="decimal"
      />
      <button class="UIsp__btn" type="button" aria-label="Increase">+</button>
    </div>

    <button type="button" @click="addRow">Add row</button>
  </div>
</template>

Two things make this safe:

  • await nextTick() — Vue updates the DOM asynchronously, so right after items.push() the new nodes aren't there yet. nextTick waits for the patch; without it rescan() would find nothing.
  • scan() is idempotent — it skips already-bound elements (it marks each one with data-uispBound), so calling it on every list change never double-binds the existing rows.

And teardown is automatic: when this component unmounts, the onUnmounted inside useSpinBox calls destroy(), which removes every listener — no leaks.