Componente Radio en Vue3: Personalización y Ejemplos

El componente Radio de Vue3 permite seleccionar una opción entre varias. Se puede personalizar mediante las siguientes propiedades:

  • opciones (Option[]): arreglo de objetos con etiqueta y valor. Por defecto: [].
  • deshabilitado (boolean): deshabilita todo el grupo. Por defecto: false.
  • vertical (boolean): disposición vertical de las opciones. Solo aplica si boton es false. Por defecto: false.
  • modelValueCheck (v-model:checked, boolean): controla el estado seleccionado del radio único. Por defecto: false.
  • espaciado (number | number[]): separación entre radios, en px. Si es vertical, es el espacio vertical. Si es un arreglo: [horizontal, vertical] para cuando hay salto de línea. Solo si boton es false. Por defecto: 8.
  • boton (boolean): usa estilo de botón. Por defecto: false.
  • estiloBoton ('outline' | 'solid'): estilo del botón (solo si boton es true). Por defecto: 'outline'.
  • tamanioBoton ('small' | 'middle' | 'large'): tamaño del botón (solo si boton es true). Por defecto: 'middle'.
  • modelValue (v-model:value, string | number | boolean): valor seleccionado actual. Por defecto: undefined.

Creación del componente Radio.vue

El siguiente código implementa el componente con las funcionalidades descritas. Utiliza las utilidades useSlotsExist y useInject (no mostradas aquí).


<script lang="ts" setup>
import { computed, ref, watchEffect, nextTick } from 'vue'
import { useSlotsExist, useInject } from 'components/utils'

export interface Opcion {
  etiqueta: string
  valor: string | number | boolean
  deshabilitado?: boolean
}

export interface Props {
  opciones?: Opcion[]
  deshabilitado?: boolean
  vertical?: boolean
  modelValueCheck?: boolean
  espaciado?: number | number[]
  boton?: boolean
  estiloBoton?: 'outline' | 'solid'
  tamanioBoton?: 'small' | 'middle' | 'large'
  modelValue?: string | number | boolean
}

const props = withDefaults(defineProps<Props>(), {
  opciones: () => [],
  deshabilitado: false,
  vertical: false,
  modelValueCheck: false,
  espaciado: 8,
  boton: false,
  estiloBoton: 'outline',
  tamanioBoton: 'middle',
  modelValue: undefined
})

const checkSeleccionado = ref<boolean>(false)
const valorSeleccionado = ref<string | number | boolean>()
const ondaActiva = ref<boolean>(false)
const { paletasColor } = useInject('Radio')
const emit = defineEmits(['update:modelValueCheck', 'update:modelValue', 'cambio'])
const slotsExisten = useSlotsExist(['default'])

const cantidadOpciones = computed(() => props.opciones.length)

const valorEspaciado = computed(() => {
  if (!props.boton) {
    if (!props.vertical && Array.isArray(props.espaciado)) {
      return `${props.espaciado[1]}px ${props.espaciado[0]}px`
    }
    return `${props.espaciado}px`
  }
  return 0
})

watchEffect(() => {
  checkSeleccionado.value = props.modelValueCheck
})

watchEffect(() => {
  valorSeleccionado.value = props.modelValue
})

function verificarDeshabilitado(deshabilitado?: boolean): boolean {
  return deshabilitado !== undefined ? deshabilitado : props.deshabilitado
}

function alHacerClic(valor: string | number | boolean): void {
  if (valor !== valorSeleccionado.value) {
    iniciarOnda()
    valorSeleccionado.value = valor
    emit('update:modelValue', valor)
    emit('cambio', valor)
  }
}

function alMarcar(): void {
  if (!checkSeleccionado.value) {
    iniciarOnda()
    checkSeleccionado.value = true
    emit('update:modelValueCheck', true)
    emit('cambio', true)
  }
}

function iniciarOnda(): void {
  ondaActiva.value = !ondaActiva.value
  nextTick(() => {
    ondaActiva.value = true
  })
}

function terminarOnda(): void {
  ondaActiva.value = false
}
</script>

<template>
  <div v-if="cantidadOpciones" class="radio-contenedor" :class="{ 'radio-vertical': !boton && vertical }" :style="`--radio-espaciado: ${valorEspaciado}; --color-primario: ${paletasColor[5]};`" v-bind="$attrs">
    <template v-if="!boton">
      <div v-for="(opc, idx) in opciones" :key="idx" class="radio-item" :class="{ 'radio-deshabilitado': verificarDeshabilitado(opc.deshabilitado) }" @click="verificarDeshabilitado(opc.deshabilitado) ? () => {} : alHacerClic(opc.valor)">
        <span class="radio-mango" :class="{ 'radio-checkeado': valorSeleccionado === opc.valor }">
          <span v-if="!verificarDeshabilitado(opc.deshabilitado)" class="radio-onda" :class="{ 'onda-activa': ondaActiva && valorSeleccionado === opc.valor }" @animationend="terminarOnda"></span>
        </span>
        <span class="radio-etiqueta">
          <slot :option="opc" :label="opc.etiqueta" :index="idx">{{ opc.etiqueta }}</slot>
        </span>
      </div>
    </template>
    <template v-else>
      <div v-for="(opc, idx) in opciones" :key="idx" tabindex="0" class="radio-boton" :class="{
        'radio-boton-checkeado': valorSeleccionado === opc.valor,
        'radio-boton-deshabilitado': verificarDeshabilitado(opc.deshabilitado),
        'radio-boton-solido': estiloBoton === 'solid',
        'radio-boton-pequeno': tamanioBoton === 'small',
        'radio-boton-grande': tamanioBoton === 'large'
      }" @click="verificarDeshabilitado(opc.deshabilitado) ? () => {} : alHacerClic(opc.valor)">
        <span class="radio-etiqueta">
          <slot :option="opc" :label="opc.etiqueta" :index="idx">{{ opc.etiqueta }}</slot>
        </span>
        <span v-if="!verificarDeshabilitado(opc.deshabilitado)" class="radio-onda" :class="{ 'onda-activa': ondaActiva && valorSeleccionado === opc.valor }" @animationend="terminarOnda"></span>
      </div>
    </template>
  </div>
  <template v-else>
    <div v-if="!boton" class="radio-item" :class="{ 'radio-deshabilitado': deshabilitado }" :style="`--color-primario: ${paletasColor[5]};`" @click="deshabilitado ? () => {} : alMarcar()" v-bind="$attrs">
      <span class="radio-mango" :class="{ 'radio-checkeado': checkSeleccionado }">
        <span v-if="!deshabilitado" class="radio-onda" :class="{ 'onda-activa': ondaActiva && checkSeleccionado }" @animationend="terminarOnda"></span>
      </span>
      <span v-if="slotsExisten.default" class="radio-etiqueta">
        <slot></slot>
      </span>
    </div>
    <div v-else tabindex="0" class="radio-boton radio-boton-unico" :class="{
      'radio-boton-checkeado': checkSeleccionado,
      'radio-boton-deshabilitado': deshabilitado,
      'radio-boton-solido': estiloBoton === 'solid',
      'radio-boton-pequeno': tamanioBoton === 'small',
      'radio-boton-grande': tamanioBoton === 'large'
    }" :style="`--color-primario: ${paletasColor[5]};`" @click="deshabilitado ? () => {} : alMarcar()" v-bind="$attrs">
      <span class="radio-etiqueta">
        <slot></slot>
      </span>
      <span v-if="!deshabilitado" class="radio-onda" :class="{ 'onda-activa': ondaActiva && checkSeleccionado }" @animationend="terminarOnda"></span>
    </div>
  </template>
</template>

<style lang="less" scoped>
/* estilos adaptados con nombres de clase modificados */
.radio-contenedor {
  display: inline-flex;
  flex-wrap: wrap;
  gap: var(--radio-espaciado);
}
.radio-vertical {
  flex-direction: column;
  flex-wrap: nowrap;
}
.radio-item {
  display: inline-flex;
  align-items: baseline;
  cursor: pointer;
  color: rgba(0,0,0,0.88);
  font-size: 14px;
  line-height: 1.571;
  &:not(.radio-deshabilitado):hover .radio-mango {
    border-color: var(--color-primario);
  }
  .radio-mango {
    flex-shrink: 0;
    align-self: center;
    position: relative;
    width: 16px;
    height: 16px;
    background: transparent;
    border: 1px solid #d9d9d9;
    border-radius: 50%;
    transition: all 0.3s;
    &::after {
      box-sizing: border-box;
      position: absolute;
      top: 50%;
      left: 50%;
      display: block;
      width: 16px;
      height: 16px;
      margin-top: -8px;
      margin-left: -8px;
      background-color: #fff;
      border-radius: 16px;
      transform: scale(0);
      opacity: 0;
      transition: all 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
      content: '';
    }
  }
  .radio-checkeado {
    border-color: var(--color-primario);
    background-color: var(--color-primario);
    &::after {
      transform: scale(0.375);
      opacity: 1;
    }
  }
  .radio-etiqueta {
    word-break: break-all;
    padding: 0 8px;
  }
}
.radio-deshabilitado {
  color: rgba(0,0,0,0.25);
  cursor: not-allowed;
  .radio-mango {
    background-color: rgba(0,0,0,0.04);
    border-color: #d9d9d9;
    &::after {
      transform: scale(0.5);
      background-color: rgba(0,0,0,0.25);
    }
  }
}
.radio-boton {
  position: relative;
  height: 32px;
  padding-inline: 15px;
  line-height: 30px;
  background: #fff;
  border: 1px solid #d9d9d9;
  cursor: pointer;
  transition: all 0.2s, box-shadow 0.2s;
  &:first-child {
    border-left: 1px solid #d9d9d9;
    border-radius: 6px 0 0 6px;
  }
  &:last-child {
    border-radius: 0 6px 6px 0;
  }
  &:not(:first-child):not(.radio-boton-unico)::before {
    position: absolute;
    top: -1px;
    left: -1px;
    content: '';
    width: 1px;
    height: 100%;
    background-color: #d9d9d9;
  }
  &:not(.radio-boton-deshabilitado):hover {
    color: var(--color-primario);
  }
}
.radio-boton-unico {
  border-left: 1px solid #d9d9d9;
  border-radius: 6px;
}
.radio-boton-checkeado:not(.radio-boton-deshabilitado) {
  z-index: 1;
  color: var(--color-primario);
  background-color: #fff;
  border-color: var(--color-primario);
  &::before {
    background-color: var(--color-primario);
  }
}
.radio-boton-deshabilitado {
  color: rgba(0,0,0,0.25);
  background-color: rgba(0,0,0,0.04);
  border-color: #d9d9d9;
  cursor: not-allowed;
  &.radio-boton-checkeado {
    background-color: rgba(0,0,0,0.15);
  }
}
.radio-boton-solido.radio-boton-checkeado:not(.radio-boton-deshabilitado) {
  color: #fff;
  background-color: var(--color-primario);
  border-color: var(--color-primario);
  &:hover { color: #fff; }
}
.radio-boton-pequeno {
  height: 24px;
  padding-inline: 7px;
  line-height: 22px;
  &:first-child { border-radius: 4px 0 0 4px; }
  &:last-child { border-radius: 0 4px 4px 0; }
  &.radio-boton-unico { border-radius: 4px; }
}
.radio-boton-grande {
  height: 40px;
  font-size: 16px;
  line-height: 38px;
  &:first-child { border-radius: 8px 0 0 8px; }
  &:last-child { border-radius: 0 8px 8px 0; }
  &.radio-boton-unico { border-radius: 8px; }
}
.radio-onda {
  position: absolute;
  pointer-events: none;
  top: 0; right: 0; bottom: 0; left: 0;
  animation-iteration-count: 1;
  animation-duration: 0.6s;
  animation-timing-function: cubic-bezier(0,0,0.2,1);
  border-radius: inherit;
}
.onda-activa {
  animation-name: ondaExpandir, ondaOpacidad;
  @keyframes ondaExpandir {
    from { box-shadow: 0 0 0.5px 0 var(--color-primario); }
    to { box-shadow: 0 0 0.5px 5px var(--color-primario); }
  }
  @keyframes ondaOpacidad {
    from { opacity: 0.6; }
    to { opacity: 0; }
  }
}
</style>

Ejemplo de uso en un componente

A continuación se muestra cómo importar y utilizar el componente Radio en una página. Se incluyen ejemplos con opciones, estilos de botón, deshabilitación y personalización de espaciado.


<script setup lang="ts">
import Radio from './Radio.vue'
import { ref, watchEffect } from 'vue'
import type { Opcion } from '../types'

const opciones = ref<Opcion[]>([
  { etiqueta: 'Madrid', valor: 1 },
  { etiqueta: 'París', valor: 2 },
  { etiqueta: 'Berlín', valor: 3 },
  { etiqueta: 'Roma', valor: 4 },
  { etiqueta: 'Londres', valor: 5 },
  { etiqueta: 'Ámsterdam', valor: 6 }
])

const opcionesDeshabilitadas = ref<Opcion[]>([
  { etiqueta: 'Madrid', valor: 1 },
  { etiqueta: 'París', valor: 2, deshabilitado: true },
  { etiqueta: 'Berlín', valor: 3 },
  { etiqueta: 'Roma', valor: 4 },
  { etiqueta: 'Londres', valor: 5 },
  { etiqueta: 'Ámsterdam', valor: 6 }
])

const opcionesTamanio = [
  { etiqueta: 'small', valor: 'small' },
  { etiqueta: 'middle', valor: 'middle' },
  { etiqueta: 'large', valor: 'large' }
]

const chequear = ref<boolean>(false)
const valorSeleccion = ref<string | number | boolean>(2)
const tamBoton = ref<'small' | 'middle' | 'large'>('middle')

watchEffect(() => console.log('checked:', chequear.value))
watchEffect(() => console.log('value:', valorSeleccion.value))

const espaciadoH = ref(16)
const espaciadoV = ref(8)

function onCambio(val: string | number | boolean) {
  console.log('cambio', val)
}
</script>

<template>
  <div>
    <h1>Radio - Ejemplos</h1>

    <h2>Uso básico</h2>
    <Radio v-model:modelValueCheck="chequear" @cambio="onCambio">Opción única</Radio>

    <h2>Lista de opciones</h2>
    <Radio :opciones="opciones" v-model:modelValue="valorSeleccion" @cambio="onCambio" />

    <h2>Estilo botón</h2>
    <Radio v-model:modelValueCheck="chequear" boton>Boton Radio</Radio>
    <Radio :opciones="opciones" v-model:modelValue="valorSeleccion" boton />

    <h2>Botón relleno</h2>
    <Radio v-model:modelValueCheck="chequear" boton estiloBoton="solid">Boton Sólido</Radio>
    <Radio :opciones="opciones" v-model:modelValue="valorSeleccion" boton estiloBoton="solid" />

    <h2>Deshabilitado</h2>
    <Radio v-model:modelValueCheck="chequear" deshabilitado>Radio deshab</Radio>
    <Radio :opciones="opciones" v-model:modelValue="valorSeleccion" deshabilitado />
    <Radio :opciones="opciones" v-model:modelValue="valorSeleccion" boton deshabilitado />

    <h2>Opciones deshabilitadas</h2>
    <Radio :opciones="opcionesDeshabilitadas" v-model:modelValue="valorSeleccion" />
    <Radio :opciones="opcionesDeshabilitadas" v-model:modelValue="valorSeleccion" boton />
    <Radio :opciones="opcionesDeshabilitadas" v-model:modelValue="valorSeleccion" boton estiloBoton="solid" />

    <h2>Disposición vertical</h2>
    <Radio vertical :opciones="opciones" v-model:modelValue="valorSeleccion" />

    <h2>Personalizar etiqueta</h2>
    <Radio :opciones="opciones" v-model:modelValue="valorSeleccion">
      <template #default="{ option, label, index }">
        <span v-if="index === 1" style="color: #ff6900">{{ label }}</span>
        <span v-if="index === 3" style="color: #1677ff">{{ option.etiqueta }}</span>
      </template>
    </Radio>

    <h2>Espaciado personalizado</h2>
    <div>
      <div>Espaciado horizontal: <input type="range" v-model.number="espaciadoH" min="0" max="40" /> {{ espaciadoH }}px</div>
      <div>Espaciado vertical: <input type="range" v-model.number="espaciadoV" min="0" max="40" /> {{ espaciadoV }}px</div>
      <Radio :espaciado="[espaciadoH, espaciadoV]" :opciones="opciones" v-model:modelValue="valorSeleccion" />
    </div>

    <h2>Tamaño del botón</h2>
    <Radio :opciones="opcionesTamanio" v-model:modelValue="tamBoton" />
    <Radio v-model:modelValueCheck="chequear" boton :tamanioBoton="tamBoton">Botón {{ tamBoton }}</Radio>
    <Radio :opciones="opciones" v-model:modelValue="valorSeleccion" boton :tamanioBoton="tamBoton" />
    <Radio :opciones="opciones" v-model:modelValue="valorSeleccion" boton estiloBoton="solid" :tamanioBoton="tamBoton" />
  </div>
</template>

Este componente ofrece fleixbilidad para adaptarse a diferentes diseños, ya sea con radios clásicos o con apariencia de botones, con opciones de tamaño, espaciado y estilos visuales.

Etiquetas: Vue3 Radio Componente Directivas v-model

Publicado el 5-31 22:45