Skip to content

fix(VCheckbox,VRadio,VRadioGroup,VSwitch,VColorInput): expose focus - #23148

Merged
J-Sek merged 17 commits into
devfrom
userquin/fix-expose-focus-at-selection-control
Oct 1, 2026
Merged

J-Sek merged 17 commits into
devfrom
userquin/fix-expose-focus-at-selection-control

Conversation

@userquin

@userquin userquin commented Aug 26, 2026 •

Copy link
Copy Markdown
Member

fixes #23147

focus() and blur() were not reachable through template refs on selection controls, and VColorInput exposed nothing.

  • VCheckbox, VCheckboxBtn, VRadio, VSwitch expose focus, blur, isFocused; the VInput API (validate, reset, …) is still forwarded
  • VRadioGroup exposes the same and emits update:focused. Focus now reaches VInput, so the group gets the focused styles, shows the hint and validates on blur like other inputs
  • VRadioGroup focus() targets the checked radio, otherwise the first enabled one in DOM order, and skips disabled radios
  • focus({ focusVisible: true }) shows the focus ring only when focus actually lands (not on disabled controls)
  • VColorInput forwards the VTextField API

Known limitation: with a radio focused, clicking another radio's label emits update:focused on the group component (false, then true). Native radios with for labels behave the same.

Markup:

packages/vuetify/dev/Playground.vue
<template>
  <v-app>
    <v-container>
      <h1 class="text-h4 mb-6">🎯 Focus API Playground</h1>

      <v-row>
        <!-- VCheckbox -->
        <v-col cols="12" md="6">
          <v-card class="pa-4" variant="outlined">
            <v-card-title>VCheckbox</v-card-title>
            <v-card-text>
              <v-checkbox
                ref="checkboxRef"
                v-model="checkboxModel"
                label="Checkbox test"
                @update:focused="checkboxFocused = $event"
              />
              <div class="text-caption font-weight-bold">
                Model: {{ checkboxModel }} | Focused: {{ checkboxFocused }}
              </div>
            </v-card-text>
            <v-card-actions style="flex-flow: wrap;">
              <v-btn color="primary" @click="checkboxRef?.focus()">.focus()</v-btn>
              <v-btn color="primary" @click="checkboxRef?.focus({ focusVisible: true })">.focus({ focusVisible: true })</v-btn>
              <v-btn color="error" @click="focusAndBlurCheckbox()">
                .focus({ focusVisible: true }) + timeout(1500) + .blur()
              </v-btn>
            </v-card-actions>
          </v-card>
        </v-col>

        <!-- VSwitch -->
        <v-col cols="12" md="6">
          <v-card class="pa-4" variant="outlined">
            <v-card-title>VSwitch</v-card-title>
            <v-card-text>
              <v-switch
                ref="switchRef"
                v-model="switchModel"
                label="Switch test"
                @update:focused="switchFocused = $event"
              />
              <div class="text-caption font-weight-bold">
                Model: {{ switchModel }} | Focused: {{ switchFocused }}
              </div>
            </v-card-text>
            <v-card-actions style="flex-flow: wrap;">
              <v-btn color="primary" @click="switchRef?.focus()">.focus()</v-btn>
              <v-btn color="primary" @click="switchRef?.focus({ focusVisible: true })">.focus({ focusVisible: true })</v-btn>
              <v-btn color="error" @click="focusAndBlurSwitch()">
                .focus({ focusVisible: true }) + timeout(1500) + .blur()
              </v-btn>
            </v-card-actions>
          </v-card>
        </v-col>

        <!-- VRadioGroup -->
        <v-col cols="12" md="6">
          <v-card class="pa-4" variant="outlined">
            <v-card-title>VRadioGroup</v-card-title>
            <v-card-subtitle>
              Try moving between the spokes using the arrow keys on your keyboard!
            </v-card-subtitle>
            <v-card-text>
              <v-radio-group
                ref="radioGroupRef"
                v-model="radioModel"
                @update:focused="radioGroupFocused = $event"
              >
                <v-radio label="Opción 1" value="op-1" />
                <v-radio label="Opción 2" value="op-2" />
                <v-radio label="Opción 3 (Disabled)" value="op-3" disabled />
                <v-radio label="Opción 4" value="op-4" />
              </v-radio-group>
              <div class="text-caption font-weight-bold">
                Model: {{ radioModel }} | Group Focused: {{ radioGroupFocused }}
              </div>
            </v-card-text>
            <v-card-actions style="flex-flow: wrap;">
              <v-btn color="primary" @click="radioGroupRef?.focus()">.focus()</v-btn>
              <v-btn color="primary" @click="radioGroupRef?.focus({ focusVisible: true })">.focus({ focusVisible: true })</v-btn>
              <v-btn color="error" @click.prevent="focusAndBlurRadioGroup" @mousedown.prevent="">
                .focus({ focusVisible: true }) + timeout(1500) + .blur()
              </v-btn>
            </v-card-actions>
          </v-card>
        </v-col>

        <!-- VColorInput -->
        <v-col cols="12" md="6">
          <v-card class="pa-4" variant="outlined">
            <v-card-title>VColorInput</v-card-title>
            <v-card-subtitle>
              Verifying that it inherits the entire VTextField API
            </v-card-subtitle>
            <v-card-text>
              <v-color-input
                ref="colorInputRef"
                v-model="colorModel"
                label="Color picker"
                @update:focused="updateColorModelFocused"
              />
              <div class="text-caption font-weight-bold mt-2">
                Model: {{ colorModel }} | Focused: {{ colorInputFocused }}
              </div>
            </v-card-text>
            <v-card-actions style="flex-flow: wrap;">
              <v-btn color="primary" @click="colorInputRef?.focus()">.focus()</v-btn>
              <v-btn color="primary" @click="colorInputRef?.focus({ focusVisible: true })">.focus({ focusVisible: true })</v-btn>
              <v-btn color="error" @click="focusAndBlurColorInput">
                .focus({ focusVisible: true }) + timeout(1500) + .blur()
              </v-btn>
              <v-btn color="warning" @click="colorInputRef?.reset()">.reset()</v-btn>
            </v-card-actions>
          </v-card>
        </v-col>
      </v-row>
    </v-container>
  </v-app>
</template>

<script setup lang="ts">
  import { ref, shallowRef, useTemplateRef } from 'vue'

  const checkboxRef = useTemplateRef('checkboxRef')
  const switchRef = useTemplateRef('switchRef')
  const radioGroupRef = useTemplateRef('radioGroupRef')
  const colorInputRef = useTemplateRef('colorInputRef')

  const checkboxModel = shallowRef(false)
  const switchModel = shallowRef(false)
  const radioModel = shallowRef('op-1')
  const colorModel = shallowRef('#FF0000')

  const checkboxFocused = ref(false)
  const switchFocused = ref(false)
  const radioGroupFocused = ref(false)
  const colorInputFocused = ref(false)

  function focusAndBlurCheckbox () {
    checkboxRef.value?.focus({ focusVisible: true })
    setTimeout(() => checkboxRef.value?.blur(), 1500)
  }

  function focusAndBlurSwitch () {
    switchRef.value?.focus({ focusVisible: true })
    setTimeout(() => switchRef.value?.blur(), 1500)
  }

  function focusAndBlurRadioGroup () {
    radioGroupRef.value?.focus({ focusVisible: true })
    setTimeout(() => radioGroupRef.value?.blur(), 1500)
  }
  function focusAndBlurColorInput () {
    colorInputRef.value?.focus({ focusVisible: true })
    setTimeout(() => colorInputRef.value?.blur(), 1500)
  }
  function updateColorModelFocused (value: boolean) {
    // VColorInput must not emit @update:focused twice for a SINGLE focus change
    // (see VColorInput.spec.browser.tsx). A false/true *pair* per button click is
    // expected and not that bug: the v-btn takes focus (blur), then focus() restores
    // it — two real changes.
    // eslint-disable-next-line no-console
    console.log('calling @update:focused with value:', value)
    colorInputFocused.value = value
  }
</script>

Comment thread packages/vuetify/src/components/VCheckbox/VCheckbox.tsx
Comment thread packages/vuetify/src/components/VRadioGroup/VRadioGroup.tsx Outdated
Comment thread packages/vuetify/src/components/VSwitch/VSwitch.tsx Outdated
Comment thread packages/vuetify/src/components/VSelectionControlGroup/VSelectionControlGroup.tsx Outdated
Comment thread packages/vuetify/src/components/VCheckbox/__tests__/VCheckbox.spec.browser.tsx Outdated
Comment thread packages/vuetify/src/components/VRadio/__tests__/VRadio.spec.browser.tsx Outdated
@userquin

userquin commented Aug 27, 2026 •

Copy link
Copy Markdown
Member Author

@KaelWD @johnleider

I've reverted the useFocus aliasing back to the original destructuring, you were totally right that it was purely cosmetic since we don't pass focus to forwardRefs.

I kept { isFocused } in the target object: forwardRefs({ isFocused }, checkboxBtnRef, inputRef). This one is actually not cosmetic and strictly necessary (check the thread at discord).
Without it, if a user passes <VCheckbox focused/>, the proxy resolves isFocused through the checkboxBtnRef chain (which defaults to VSelectionControl's shallowRef(false)), exposing the wrong state. Shadowing it at the target level ensures we expose VCheckbox's actual useProxiedModel state.

I'm going to add some changes for VSelectionControl and VRadioGroup at api docs and the PR should be ready for review.

@userquin

userquin commented Aug 27, 2026 •

Copy link
Copy Markdown
Member Author

I need to revert a change...

EDIT: VRadioGroup logic is fine at 125f1a2, so no changes are necessary

@cursor
cursor Bot force-pushed the userquin/fix-expose-focus-at-selection-control branch from d8f54f4 to 143eccd Compare September 15, 2026 21:50
@johnleider

This comment was marked as outdated.

The test incorrectly passed the ref object directly (v-model={model}) instead
of its value (v-model={model.value}), causing 'Assignment to constant variable'
error when the radio button was clicked.
@KaelWD

KaelWD commented Sep 16, 2026

Copy link
Copy Markdown
Member

Can you tell your clanker to merge instead of rebase next time so I can tell what was actually changed.

@KaelWD
KaelWD force-pushed the userquin/fix-expose-focus-at-selection-control branch from bd6f010 to f3aa699 Compare September 16, 2026 05:33
KaelWD
KaelWD previously approved these changes Sep 16, 2026
@J-Sek
J-Sek force-pushed the master branch 2 times, most recently from 630451f to 3d3f418 Compare September 17, 2026 10:22
@J-Sek
J-Sek changed the base branch from master to dev October 1, 2026 09:40

function onFocusout (e: FocusEvent) {
if (!(e.currentTarget as HTMLElement)?.contains(e.relatedTarget as Node)) {
blur()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: fires for mousedown on the inner v-radio label. Same as when using pure native controls (unless label wraps the input)

The only reliable way to counter this would be to preventDefault on label mousedown. I don't think I want to cover this until I see any real-life scenario affected.

@J-Sek
J-Sek merged commit ae6934a into dev Oct 1, 2026
16 checks passed
@J-Sek
J-Sek deleted the userquin/fix-expose-focus-at-selection-control branch October 1, 2026 10:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug Report][4.1.10] focus() is not exposed on VCheckbox, VRadioGroup, VSwitch or VColorInput

4 participants