Repository: mui/material-ui (TypeScript)
Date: 2021-08-23 09:15:32+00:00
Task type: Feature

Issue:
[Switch] Add `track` to unstyled switch
## Summary 💡

Unstyled switch should provide `track` element by default (for styling purpose). I think unstyled package not only take care of the functionality but should also provide default anatomy that suitable for styling.

This [preview](https://deploy-preview-27488--material-ui.netlify.app/branding/home/) demonstrate a bug when the switch has `thumb` bigger than `root`. Clicking on the edge of thumb (outside of track) does not trigger.

<img width="99" alt="Screen Shot 2564-08-23 at 10 52 32" src="https://user-images.githubusercontent.com/18292247/130387911-fa262c75-7924-406e-8150-86974698d9b1.png">

Also, the [unstyled demo](https://next.material-ui.com/components/switches/#unstyled-switches) should style `track` not `input` so that the developer can start with a correct styling.

Here is my experience using unstyled switch.
1. I import Switch from unstyled and copy the styling from the demo as a baseline
2. add icon to thumb, customize css on top of the baseline (like, increase thumb size, root size, ...etc)
3. tested on the browser, and found out that clicking on the edge does not work.
4. spend some time to figure out what did I do wrong. Could not figure it out until I compared with `@material-ui/core/switch` and found out that it has `track` element.
5. need to add `track` element on my own and rework all the css because the styling of `input` should be the `track` and `root` area should cover the whole switch.

So I think it is better to have built-in `track` element in unstyled switch and fix the demo css to style `track` instead of `input`, (optional) add some note about the styling that `root` should cover the whole switch to prevent this bug.

cc @michaldudak 



Issue discussion:

Solution patch:
diff --git a/docs/pages/api-docs/switch-unstyled.json b/docs/pages/api-docs/switch-unstyled.json
--- a/docs/pages/api-docs/switch-unstyled.json
+++ b/docs/pages/api-docs/switch-unstyled.json
@@ -6,7 +6,7 @@
     "components": {
       "type": {
         "name": "shape",
-        "description": "{ Input?: elementType, Root?: elementType, Thumb?: elementType }"
+        "description": "{ Input?: elementType, Root?: elementType, Thumb?: elementType, Track?: elementType<br>&#124;&nbsp;null }"
       },
       "default": "{}"
     },
diff --git a/docs/src/pages/components/switches/UnstyledSwitches.js b/docs/src/pages/components/switches/UnstyledSwitches.js
--- a/docs/src/pages/components/switches/UnstyledSwitches.js
+++ b/docs/src/pages/components/switches/UnstyledSwitches.js
@@ -10,8 +10,7 @@ const Root = styled('span')(`
   display: inline-block;
   width: 32px;
   height: 20px;
-  background: #B3C3D3;
-  border-radius: 10px;
+  
   margin: 10px;
   cursor: pointer;
 
@@ -20,8 +19,13 @@ const Root = styled('span')(`
     cursor: not-allowed;
   }
 
-  &.${switchUnstyledClasses.checked} {
-    background: #007FFF;
+  & .${switchUnstyledClasses.track} {
+    background: #B3C3D3;
+    border-radius: 10px;
+    display: block;
+    height: 100%;
+    width: 100%;
+    position: absolute;
   }
 
   & .${switchUnstyledClasses.thumb} {
@@ -41,10 +45,16 @@ const Root = styled('span')(`
     box-shadow: 0 0 1px 8px rgba(0,0,0,0.25);
   }
 
-  &.${switchUnstyledClasses.checked} .${switchUnstyledClasses.thumb} {
-    left: 14px;
-    top: 3px;
-    background-color: #FFF;
+  &.${switchUnstyledClasses.checked} { 
+    .${switchUnstyledClasses.thumb} {
+      left: 14px;
+      top: 3px;
+      background-color: #FFF;
+    }
+
+    .${switchUnstyledClasses.track} {
+      background: #007FFF;
+    }
   }
 
   & .${switchUnstyledClasses.input} {
diff --git a/docs/src/pages/components/switches/UnstyledSwitches.tsx b/docs/src/pages/components/switches/UnstyledSwitches.tsx
--- a/docs/src/pages/components/switches/UnstyledSwitches.tsx
+++ b/docs/src/pages/components/switches/UnstyledSwitches.tsx
@@ -10,8 +10,7 @@ const Root = styled('span')(`
   display: inline-block;
   width: 32px;
   height: 20px;
-  background: #B3C3D3;
-  border-radius: 10px;
+  
   margin: 10px;
   cursor: pointer;
 
@@ -20,8 +19,13 @@ const Root = styled('span')(`
     cursor: not-allowed;
   }
 
-  &.${switchUnstyledClasses.checked} {
-    background: #007FFF;
+  & .${switchUnstyledClasses.track} {
+    background: #B3C3D3;
+    border-radius: 10px;
+    display: block;
+    height: 100%;
+    width: 100%;
+    position: absolute;
   }
 
   & .${switchUnstyledClasses.thumb} {
@@ -41,10 +45,16 @@ const Root = styled('span')(`
     box-shadow: 0 0 1px 8px rgba(0,0,0,0.25);
   }
 
-  &.${switchUnstyledClasses.checked} .${switchUnstyledClasses.thumb} {
-    left: 14px;
-    top: 3px;
-    background-color: #FFF;
+  &.${switchUnstyledClasses.checked} { 
+    .${switchUnstyledClasses.thumb} {
+      left: 14px;
+      top: 3px;
+      background-color: #FFF;
+    }
+
+    .${switchUnstyledClasses.track} {
+      background: #007FFF;
+    }
   }
 
   & .${switchUnstyledClasses.input} {
diff --git a/docs/src/pages/components/switches/UnstyledSwitchesMaterial.js b/docs/src/pages/components/switches/UnstyledSwitchesMaterial.js
--- a/docs/src/pages/components/switches/UnstyledSwitchesMaterial.js
+++ b/docs/src/pages/components/switches/UnstyledSwitchesMaterial.js
@@ -383,6 +383,7 @@ const Switch = React.forwardRef(function Switch(inProps, ref) {
     Root: SwitchLayout,
     Input: SwitchInput,
     Thumb: SwitchThumb,
+    Track: null,
   };
 
   const componentsProps = {
diff --git a/packages/material-ui-unstyled/src/SwitchUnstyled/SwitchUnstyled.tsx b/packages/material-ui-unstyled/src/SwitchUnstyled/SwitchUnstyled.tsx
--- a/packages/material-ui-unstyled/src/SwitchUnstyled/SwitchUnstyled.tsx
+++ b/packages/material-ui-unstyled/src/SwitchUnstyled/SwitchUnstyled.tsx
@@ -25,6 +25,7 @@ export interface SwitchUnstyledProps extends UseSwitchProps {
     Root?: React.ElementType;
     Thumb?: React.ElementType;
     Input?: React.ElementType;
+    Track?: React.ElementType | null;
   };
 
   /**
@@ -35,6 +36,7 @@ export interface SwitchUnstyledProps extends UseSwitchProps {
     root?: {};
     thumb?: {};
     input?: {};
+    track?: {};
   };
 }
 
@@ -100,6 +102,10 @@ const SwitchUnstyled = React.forwardRef(function SwitchUnstyled(
   const Input: React.ElementType = components.Input ?? 'input';
   const inputProps = appendOwnerState(Input, componentsProps.input ?? {}, ownerState);
 
+  const Track: React.ElementType =
+    components.Track === null ? () => null : components.Track ?? 'span';
+  const trackProps = appendOwnerState(Track, componentsProps.track ?? {}, ownerState);
+
   const stateClasses = {
     [classes.checked]: checked,
     [classes.disabled]: disabled,
@@ -113,6 +119,7 @@ const SwitchUnstyled = React.forwardRef(function SwitchUnstyled(
       {...rootProps}
       className={clsx(classes.root, stateClasses, className, rootProps?.className)}
     >
+      <Track {...trackProps} className={clsx(classes.track, trackProps?.className)} />
       <Thumb {...thumbProps} className={clsx(classes.thumb, thumbProps?.className)} />
       <Input
         {...getInputProps(inputProps)}
@@ -146,10 +153,11 @@ SwitchUnstyled.propTypes /* remove-proptypes */ = {
    * Either a string to use a HTML element or a component.
    * @default {}
    */
-  components: PropTypes.shape({
+  components: PropTypes /* @typescript-to-proptypes-ignore */.shape({
     Input: PropTypes.elementType,
     Root: PropTypes.elementType,
     Thumb: PropTypes.elementType,
+    Track: PropTypes.oneOfType([PropTypes.elementType, PropTypes.oneOf([null])]),
   }),
   /**
    * The props used for each slot inside the Switch.
diff --git a/packages/material-ui-unstyled/src/SwitchUnstyled/switchUnstyledClasses.ts b/packages/material-ui-unstyled/src/SwitchUnstyled/switchUnstyledClasses.ts
--- a/packages/material-ui-unstyled/src/SwitchUnstyled/switchUnstyledClasses.ts
+++ b/packages/material-ui-unstyled/src/SwitchUnstyled/switchUnstyledClasses.ts
@@ -6,6 +6,8 @@ export interface SwitchUnstyledClasses {
   root: string;
   /** Class applied to the internal input element */
   input: string;
+  /** Class applied to the track element */
+  track: string;
   /** Class applied to the thumb element */
   thumb: string;
   /** Class applied to the root element if the switch is checked */
@@ -27,6 +29,7 @@ export function getSwitchUnstyledUtilityClass(slot: string): string {
 const switchUnstyledClasses: SwitchUnstyledClasses = generateUtilityClasses('MuiSwitch', [
   'root',
   'input',
+  'track',
   'thumb',
   'checked',
   'disabled',


Test patch:
diff --git a/packages/material-ui-unstyled/src/SwitchUnstyled/SwitchUnstyled.test.tsx b/packages/material-ui-unstyled/src/SwitchUnstyled/SwitchUnstyled.test.tsx
--- a/packages/material-ui-unstyled/src/SwitchUnstyled/SwitchUnstyled.test.tsx
+++ b/packages/material-ui-unstyled/src/SwitchUnstyled/SwitchUnstyled.test.tsx
@@ -28,6 +28,10 @@ describe('<SwitchUnstyled />', () => {
         testWithElement: 'input',
         expectedClassName: switchUnstyledClasses.input,
       },
+      track: {
+        expectedClassName: switchUnstyledClasses.track,
+        isOptional: true,
+      },
     },
   }));
 
diff --git a/test/utils/describeConformanceUnstyled.tsx b/test/utils/describeConformanceUnstyled.tsx
--- a/test/utils/describeConformanceUnstyled.tsx
+++ b/test/utils/describeConformanceUnstyled.tsx
@@ -15,6 +15,7 @@ export interface SlotTestingOptions {
   testWithComponent?: React.ComponentType;
   testWithElement?: keyof JSX.IntrinsicElements;
   expectedClassName: string;
+  isOptional?: boolean;
 }
 
 export interface UnstyledConformanceOptions
@@ -149,6 +150,17 @@ function testComponentsProp(
       const thumb = container.querySelector(slotElement);
       expect(thumb).to.have.class(slotOptions.expectedClassName);
     });
+
+    if (slotOptions.isOptional) {
+      it(`alows omitting the optional ${capitalize(slotName)} slot by providing null`, () => {
+        const components = {
+          [capitalize(slotName)]: null,
+        };
+
+        const { container } = render(React.cloneElement(element, { components }));
+        expect(container.querySelectorAll(`.${slotOptions.expectedClassName}`)).to.have.length(0);
+      });
+    }
   });
 
   it('uses the component provided in component prop when both component and components.Root are provided', () => {