Repository: tailwindlabs/headlessui
Pull request #2966 (2024-02-03 16:18:36)

Allow setting custom `tabIndex` on the `<Switch />` component
This PR allows overriding the `tabIndex` of the `<Switch />` component if you need to change the tab index. However, we still need it to be focusable using the keyboard so we will always fallback to `0` even if `-1` was pased.

Fixes: #2915



vercel[bot]: [vc]: #62L27VL5fhxoxrpYowr4T76pFVp3yVeONxx5DJmVItA=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJoZWFkbGVzc3VpLXZ1ZSIsInJvb3REaXJlY3RvcnkiOiJwbGF5Z3JvdW5kcy92dWUiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGFpbHdpbmRsYWJzL2hlYWRsZXNzdWktdnVlL0cyemZ0TjdnN0Q1c3RQTHppU2tmTlFTaFg2QUwiLCJwcmV2aWV3VXJsIjoiaGVhZGxlc3N1aS12dWUtZ2l0LWZpeC1pc3N1ZS0yOTE1LXRhaWx3aW5kbGFicy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IlBFTkRJTkciLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiaGVhZGxlc3N1aS12dWUtZ2l0LWZpeC1pc3N1ZS0yOTE1LXRhaWx3aW5kbGFicy52ZXJjZWwuYXBwIn19XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **headlessui-vue** | 🔄 Building ([Inspect](https://vercel.com/tailwindlabs/headlessui-vue/G2zftN7g7D5stPLziSkfNQShX6AL)) | [Visit Preview](https://vercel.live/open-feedback/headlessui-vue-git-fix-issue-2915-tailwindlabs.vercel.app?via=pr-comment-visit-preview-link&passThrough=1) | 💬 [**Add feedback**](https://vercel.live/open-feedback/headlessui-vue-git-fix-issue-2915-tailwindlabs.vercel.app?via=pr-comment-feedback-link) | Feb 3, 2024 4:18pm |



Patch:
diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 61c0898..3908d51 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Ensure `children` prop of `Field` component can be a render prop ([#2941](https://github.com/tailwindlabs/headlessui/pull/2941))
 - Add `hidden` attribute to internal `<Hidden />` component when the `Features.Hidden` feature is used ([#2955](https://github.com/tailwindlabs/headlessui/pull/2955))
 - Attempt form submission when pressing `Enter` on `Checkbox` component ([#2962](https://github.com/tailwindlabs/headlessui/pull/2962))
+- Allow setting custom `tabIndex` on the `<Switch />` component ([#2966](https://github.com/tailwindlabs/headlessui/pull/2966))
 
 ## [2.0.0-alpha.4] - 2024-01-03
 
diff --git a/packages/@headlessui-react/src/components/switch/switch.tsx b/packages/@headlessui-react/src/components/switch/switch.tsx
index 3adb57b..76a4ab8 100644
--- a/packages/@headlessui-react/src/components/switch/switch.tsx
+++ b/packages/@headlessui-react/src/components/switch/switch.tsx
@@ -116,12 +116,7 @@ type SwitchRenderPropArg = {
   changing: boolean
   disabled: boolean
 }
-type SwitchPropsWeControl =
-  | 'aria-checked'
-  | 'aria-describedby'
-  | 'aria-labelledby'
-  | 'role'
-  | 'tabIndex'
+type SwitchPropsWeControl = 'aria-checked' | 'aria-describedby' | 'aria-labelledby' | 'role'
 
 export type SwitchProps<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG> = Props<
   TTag,
@@ -136,6 +131,7 @@ export type SwitchProps<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG> =
     form?: string
     autoFocus?: boolean
     disabled?: boolean
+    tabIndex?: number
   }
 >
 
@@ -220,7 +216,7 @@ function SwitchFn<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
       ref: switchRef,
       role: 'switch',
       type: useResolveButtonType(props, internalSwitchRef),
-      tabIndex: 0,
+      tabIndex: props.tabIndex === -1 ? 0 : props.tabIndex ?? 0,
       'aria-checked': checked,
       'aria-labelledby': labelledBy,
       'aria-describedby': describedBy,
diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md
index bbcd06e..a778aa6 100644
--- a/packages/@headlessui-vue/CHANGELOG.md
+++ b/packages/@headlessui-vue/CHANGELOG.md
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Prevent default behaviour when clicking outside of a `DialogPanel` ([#2919](https://github.com/tailwindlabs/headlessui/pull/2919))
 - Don’t override explicit `disabled` prop for components inside `<MenuItem>` ([#2929](https://github.com/tailwindlabs/headlessui/pull/2929))
 - Add `hidden` attribute to internal `<Hidden />` component when the `Features.Hidden` feature is used ([#2955](https://github.com/tailwindlabs/headlessui/pull/2955))
+- Allow setting custom `tabIndex` on the `<Switch />` component ([#2966](https://github.com/tailwindlabs/headlessui/pull/2966))
 
 ## [1.7.17] - 2024-01-08
 
diff --git a/packages/@headlessui-vue/src/components/switch/switch.ts b/packages/@headlessui-vue/src/components/switch/switch.ts
index cd10354..4746dee 100644
--- a/packages/@headlessui-vue/src/components/switch/switch.ts
+++ b/packages/@headlessui-vue/src/components/switch/switch.ts
@@ -78,6 +78,7 @@ export let Switch = defineComponent({
     name: { type: String, optional: true },
     value: { type: String, optional: true },
     id: { type: String, default: () => `headlessui-switch-${useId()}` },
+    tabIndex: { type: Number, default: 0 },
   },
   inheritAttrs: false,
   setup(props, { emit, attrs, slots, expose }) {
@@ -143,14 +144,14 @@ export let Switch = defineComponent({
     })
 
     return () => {
-      let { id, name, value, form, ...theirProps } = props
+      let { id, name, value, form, tabIndex, ...theirProps } = props
       let slot = { checked: checked.value }
       let ourProps = {
         id,
         ref: switchRef,
         role: 'switch',
         type: type.value,
-        tabIndex: 0,
+        tabIndex: tabIndex === -1 ? 0 : tabIndex,
         'aria-checked': checked.value,
         'aria-labelledby': api?.labelledby.value,
         'aria-describedby': api?.describedby.value,


Test patch:
diff --git a/packages/@headlessui-react/src/components/switch/switch.test.tsx b/packages/@headlessui-react/src/components/switch/switch.test.tsx
index c65ddf5..b6988f2 100644
--- a/packages/@headlessui-react/src/components/switch/switch.test.tsx
+++ b/packages/@headlessui-react/src/components/switch/switch.test.tsx
@@ -59,6 +59,47 @@ describe('Rendering', () => {
     assertSwitch({ state: SwitchState.Off, label: 'Enable notifications' })
   })
 
+  describe('`tabIndex` attribute', () => {
+    it('should have a default tabIndex of `0`', () => {
+      render(
+        <Switch checked={false} onChange={console.log}>
+          <span>Enable notifications</span>
+        </Switch>
+      )
+      assertSwitch({
+        state: SwitchState.Off,
+        label: 'Enable notifications',
+        attributes: { tabindex: '0' },
+      })
+    })
+
+    it('should be possible to override the `tabIndex`', () => {
+      render(
+        <Switch checked={false} onChange={console.log} tabIndex={3}>
+          <span>Enable notifications</span>
+        </Switch>
+      )
+      assertSwitch({
+        state: SwitchState.Off,
+        label: 'Enable notifications',
+        attributes: { tabindex: '3' },
+      })
+    })
+
+    it('should not be possible to override the `tabIndex` to `-1`', () => {
+      render(
+        <Switch checked={false} onChange={console.log} tabIndex={-1}>
+          <span>Enable notifications</span>
+        </Switch>
+      )
+      assertSwitch({
+        state: SwitchState.Off,
+        label: 'Enable notifications',
+        attributes: { tabindex: '0' },
+      })
+    })
+  })
+
   describe('`type` attribute', () => {
     it('should set the `type` to "button" by default', async () => {
       render(
diff --git a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts
index 1b7beff..cf30183 100644
--- a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts
+++ b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts
@@ -1145,6 +1145,7 @@ export function assertSwitch(
     textContent?: string
     label?: string
     description?: string
+    attributes?: Record<string, string | null>
   },
   switchElement = getSwitch()
 ) {
@@ -1152,7 +1153,8 @@ export function assertSwitch(
     if (switchElement === null) return expect(switchElement).not.toBe(null)
 
     expect(switchElement).toHaveAttribute('role', 'switch')
-    expect(switchElement).toHaveAttribute('tabindex', '0')
+    let tabIndex = Number(switchElement.getAttribute('tabindex') ?? '0')
+    expect(tabIndex).toBeGreaterThanOrEqual(0)
 
     if (options.textContent) {
       expect(switchElement).toHaveTextContent(options.textContent)
@@ -1182,6 +1184,11 @@ export function assertSwitch(
       default:
         assertNever(options.state)
     }
+
+    // Ensure disclosure button has the following attributes
+    for (let attributeName in options.attributes) {
+      expect(switchElement).toHaveAttribute(attributeName, options.attributes[attributeName])
+    }
   } catch (err) {
     if (err instanceof Error) Error.captureStackTrace(err, assertSwitch)
     throw err
diff --git a/packages/@headlessui-vue/src/components/switch/switch.test.tsx b/packages/@headlessui-vue/src/components/switch/switch.test.tsx
index 0b6c510..c4008b4 100644
--- a/packages/@headlessui-vue/src/components/switch/switch.test.tsx
+++ b/packages/@headlessui-vue/src/components/switch/switch.test.tsx
@@ -327,6 +327,38 @@ describe('Rendering', () => {
       expect(handleChange).toHaveBeenNthCalledWith(3, true)
     })
   })
+
+  describe('`tabIndex` attribute', () => {
+    it('should have a default tabIndex of `0`', () => {
+      renderTemplate(html`<Switch :checked="false" :tabIndex="0">Enable notifications</Switch>`)
+
+      assertSwitch({
+        state: SwitchState.Off,
+        label: 'Enable notifications',
+        attributes: { tabindex: '0' },
+      })
+    })
+
+    it('should be possible to override the `tabIndex`', () => {
+      renderTemplate(html`<Switch :checked="false" :tabIndex="3">Enable notifications</Switch>`)
+
+      assertSwitch({
+        state: SwitchState.Off,
+        label: 'Enable notifications',
+        attributes: { tabindex: '3' },
+      })
+    })
+
+    it('should not be possible to override the `tabIndex` to `-1`', () => {
+      renderTemplate(html`<Switch :checked="false" :tabIndex="-1">Enable notifications</Switch>`)
+
+      assertSwitch({
+        state: SwitchState.Off,
+        label: 'Enable notifications',
+        attributes: { tabindex: '0' },
+      })
+    })
+  })
 })
 
 describe('Render composition', () => {
diff --git a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts
index 2c7bbd1..5d59ed8 100644
--- a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts
+++ b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts
@@ -978,6 +978,7 @@ export function assertSwitch(
     textContent?: string
     label?: string
     description?: string
+    attributes?: Record<string, string | null>
   },
   switchElement = getSwitch()
 ) {
@@ -985,7 +986,8 @@ export function assertSwitch(
     if (switchElement === null) return expect(switchElement).not.toBe(null)
 
     expect(switchElement).toHaveAttribute('role', 'switch')
-    expect(switchElement).toHaveAttribute('tabindex', '0')
+    let tabIndex = Number(switchElement.getAttribute('tabindex') ?? '0')
+    expect(tabIndex).toBeGreaterThanOrEqual(0)
 
     if (options.textContent) {
       expect(switchElement).toHaveTextContent(options.textContent)
@@ -1015,6 +1017,11 @@ export function assertSwitch(
       default:
         assertNever(options.state)
     }
+
+    // Ensure disclosure button has the following attributes
+    for (let attributeName in options.attributes) {
+      expect(switchElement).toHaveAttribute(attributeName, options.attributes[attributeName])
+    }
   } catch (err) {
     if (err instanceof Error) Error.captureStackTrace(err, assertSwitch)
     throw err