Repository: mui/material-ui (TypeScript)
Date: 2023-08-10 19:58:24+00:00
Task type: Bug Fix

Issue:
[RFC][useButton][base] tabIndex should be returned by getRootProps if explicitly provided
### What's the problem? 🤔

The problem lies in [this section](https://github.com/mui/material-ui/blob/master/packages/mui-base/src/useButton/useButton.ts#L190-L206) of `useButton`'s implementation:

There are some conditions under which a tabIndex value will come in `getRootProps`, and some conditions under which it won't, for example:
- It won't come when the host element is a `BUTTON`
- It will come when using it for a link and the link is disabled

This makes for a confusing API when the user provides a `tabIndex`:

```tsx
const { getRootProps } = useButton({ tabIndex: 1 });

// button won't get tabIndex=1 even though explicitly provided
<button {...getRootProps()}>Button</button>
``` 

### What are the requirements? ❓

`getRootProps` return value should contain the `tabIndex` if explicitly provided


### What are our options? 💡

- Not receiving a `tabIndex`, if the user wants to provide a specific value it would do so directly to the button element. The user can also decide to do so depending on if `getRootProps` comes with a value for `tabIndex` or not.
- Keep implementation as is and document this behavior


### Proposed solution 🟢

- If no `tabIndex` value is provided, maintain the current behavior
- If a `tabIndex` value is provided, that will come in `getRootProps`, except if `focusableWhenDisabled` is true [here](https://github.com/mui/material-ui/blob/master/packages/mui-base/src/useButton/useButton.ts#L204), in which case it will be `-1`

I think this implementation should achieve it:

```diff
  const buttonProps: AdditionalButtonProps = {};
+ buttonProps.tabIndex = tabIndex;

  if (hostElementName === 'BUTTON') {
    buttonProps.type = type ?? 'button';
    if (focusableWhenDisabled) {
      buttonProps['aria-disabled'] = disabled;
    } else {
      buttonProps.disabled = disabled;
    }
  } else if (hostElementName !== '') {
    if (!href && !to) {
      buttonProps.role = 'button';
      buttonProps.tabIndex = tabIndex ?? 0;
    }
    if (disabled) {
      buttonProps['aria-disabled'] = disabled as boolean;
      buttonProps.tabIndex = focusableWhenDisabled ? tabIndex ?? 0 : -1;
    }
  }
```

### Resources and benchmarks 🔗

- React Aria's `useButton` works as the proposed solution: https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/button/src/useButton.ts (It would merge the provided `tabIndex` on line 101)


Issue discussion:

Solution patch:
diff --git a/packages/mui-base/src/useButton/useButton.ts b/packages/mui-base/src/useButton/useButton.ts
--- a/packages/mui-base/src/useButton/useButton.ts
+++ b/packages/mui-base/src/useButton/useButton.ts
@@ -187,6 +187,10 @@ export function useButton(parameters: UseButtonParameters = {}): UseButtonReturn
 
   const buttonProps: AdditionalButtonProps = {};
 
+  if (tabIndex !== undefined) {
+    buttonProps.tabIndex = tabIndex;
+  }
+
   if (hostElementName === 'BUTTON') {
     buttonProps.type = type ?? 'button';
     if (focusableWhenDisabled) {


Test patch:
diff --git a/packages/mui-base/src/useButton/useButton.test.tsx b/packages/mui-base/src/useButton/useButton.test.tsx
--- a/packages/mui-base/src/useButton/useButton.test.tsx
+++ b/packages/mui-base/src/useButton/useButton.test.tsx
@@ -197,4 +197,46 @@ describe('useButton', () => {
       });
     });
   });
+
+  describe('tabIndex', () => {
+    it('does not return tabIndex in getRootProps when host component is BUTTON', () => {
+      function TestComponent() {
+        const ref = React.useRef(null);
+        const { getRootProps } = useButton({ rootRef: ref });
+
+        expect(getRootProps().tabIndex).to.equal(undefined);
+
+        return <button {...getRootProps()} />;
+      }
+
+      const { getByRole } = render(<TestComponent />);
+      expect(getByRole('button')).to.have.property('tabIndex', 0);
+    });
+
+    it('returns tabIndex in getRootProps when host component is not BUTTON', () => {
+      function TestComponent() {
+        const ref = React.useRef(null);
+        const { getRootProps } = useButton({ rootRef: ref });
+
+        expect(getRootProps().tabIndex).to.equal(ref.current ? 0 : undefined);
+
+        return <span {...getRootProps()} />;
+      }
+
+      const { getByRole } = render(<TestComponent />);
+      expect(getByRole('button')).to.have.property('tabIndex', 0);
+    });
+
+    it('returns tabIndex in getRootProps if it is explicitly provided', () => {
+      const customTabIndex = 3;
+      function TestComponent() {
+        const ref = React.useRef(null);
+        const { getRootProps } = useButton({ rootRef: ref, tabIndex: customTabIndex });
+        return <button {...getRootProps()} />;
+      }
+
+      const { getByRole } = render(<TestComponent />);
+      expect(getByRole('button')).to.have.property('tabIndex', customTabIndex);
+    });
+  });
 });