Skip to content

Commit 39c52df

Browse files
zernoniaclaude
andauthored
perf(Checkbox): skip label querySelector when aria-label is provided (#2715)
Closes #2318. `CheckboxRoot` resolved a fallback aria-label by running `document.querySelector([for="<id>"])`. The query is now guarded inside the computed so it never runs when an explicit `aria-label` is passed — avoiding hundreds of DOM lookups when rendering many checkboxes (e.g. a data table). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bad0940 commit 39c52df

2 files changed

Lines changed: 42 additions & 5 deletions

File tree

packages/core/src/Checkbox/Checkbox.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { DOMWrapper, VueWrapper } from '@vue/test-utils'
22
import { mount } from '@vue/test-utils'
3-
import { afterAll, beforeEach, describe, expect, it } from 'vitest'
3+
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
44
import { axe } from 'vitest-axe'
5+
import { nextTick } from 'vue'
56
import { handleSubmit } from '@/test'
7+
import { CheckboxRoot } from '.'
68
import Checkbox from './story/_Checkbox.vue'
79
import CheckboxGroup from './story/_CheckboxGroup.vue'
810

@@ -62,6 +64,34 @@ describe('given a default Checkbox', () => {
6264
})
6365
})
6466

67+
describe('given a Checkbox with an explicit aria-label', () => {
68+
afterEach(() => {
69+
vi.restoreAllMocks()
70+
})
71+
72+
it('should not query the DOM for a label', () => {
73+
const querySpy = vi.spyOn(document, 'querySelector')
74+
const wrapper = mount(CheckboxRoot, {
75+
attachTo: document.body,
76+
attrs: { 'id': 'with-label', 'aria-label': 'Accept terms' },
77+
})
78+
79+
expect(wrapper.find('button').attributes('aria-label')).toBe('Accept terms')
80+
expect(querySpy).not.toHaveBeenCalledWith('[for="with-label"]')
81+
})
82+
83+
it('should query for the associated label when no aria-label is given', async () => {
84+
const querySpy = vi.spyOn(document, 'querySelector')
85+
mount(CheckboxRoot, {
86+
attachTo: document.body,
87+
attrs: { id: 'without-label' },
88+
})
89+
await nextTick()
90+
91+
expect(querySpy).toHaveBeenCalledWith('[for="without-label"]')
92+
})
93+
})
94+
6595
describe('given a required Checkbox', () => {
6696
const wrapper = mount({
6797
components: { Checkbox },

packages/core/src/Checkbox/CheckboxRoot.vue

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const [injectCheckboxRootContext, provideCheckboxRootContext]
4747

4848
<script setup lang="ts" generic="T = boolean">
4949
import { isEqual } from 'ohash'
50-
import { computed } from 'vue'
50+
import { computed, useAttrs } from 'vue'
5151
import { Primitive } from '@/Primitive'
5252
import { RovingFocusItem } from '@/RovingFocus'
5353
import { VisuallyHiddenInput } from '@/VisuallyHidden'
@@ -122,9 +122,16 @@ function handleClick() {
122122
}
123123
124124
const isFormControl = useFormControl(currentElement)
125-
const ariaLabel = computed(() => props.id && currentElement.value
126-
? (document.querySelector(`[for="${props.id}"]`) as HTMLLabelElement)?.innerText
127-
: undefined)
125+
const attrs = useAttrs()
126+
const ariaLabel = computed(() => {
127+
// An explicit `aria-label` always wins, so skip the (potentially expensive)
128+
// label lookup entirely — this matters when rendering many checkboxes at once.
129+
if (attrs['aria-label'])
130+
return undefined
131+
return props.id && currentElement.value
132+
? (document.querySelector(`[for="${props.id}"]`) as HTMLLabelElement)?.innerText
133+
: undefined
134+
})
128135
129136
provideCheckboxRootContext({
130137
disabled,

0 commit comments

Comments
 (0)