Cambia

Questo pattern mostra come creare un componente Switch adattabile, adattivo e accessibile.

Articolo completo · Video su YouTube · Fonte su GitHub

<label for="switch-1" class="gui-switch">
  Default
 
<input type="checkbox" role="switch" id="switch-1">
</label>

<label for="switch-2" class="gui-switch">
  Indeterminate
 
<input type="checkbox" role="switch" id="switch-2">
 
<!-- TODO: Devsite - Removed inline handlers -->
 
<!-- <script>document.getElementById('switch-2').indeterminate = true</script> -->
</label>

<label for="switch-3" class="gui-switch">
  Disabled
 
<input type="checkbox" role="switch" id="switch-3" disabled>
</label>

<label for="switch-4" class="gui-switch">
  Disabled (checked)
 
<input type="checkbox" role="switch" id="switch-4" disabled checked>
</label>

<label for="switch-vertical" class="gui-switch -vertical">
  Vertical
 
<input type="checkbox" role="switch" id="switch-vertical">
</label>

       
.gui-switch {
 
--thumb-size: 2rem;
 
--thumb: hsl(0 0% 100%);
 
--thumb-highlight: hsl(0 0% 0% / 25%);
 
 
--track-size: calc(var(--thumb-size) * 2);
 
--track-padding: 2px;
 
--track-inactive: hsl(80 0% 80%);
 
--track-active: hsl(80 60% 45%);

 
--thumb-color: var(--thumb);
 
--thumb-color-highlight: var(--thumb-highlight);
 
--track-color-inactive: var(--track-inactive);
 
--track-color-active: var(--track-active);

 
--isLTR: 1;

 
display: flex;
 
align-items: center;
 
gap: 2ch;
 
justify-content: space-between;

 
cursor: pointer;
 
user-select: none;
 
-webkit-tap-highlight-color: transparent;

 
@media (prefers-color-scheme: dark) {
   
--thumb: hsl(0 0% 5%);
   
--thumb-highlight: hsl(0 0% 100% / 25%);
   
--track-inactive: hsl(80 0% 35%);
   
--track-active: hsl(80 60% 60%);
 
}

 
&:dir(rtl) {
   
--isLTR: -1;
 
}

 
&.-vertical {
   
min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));

   
& > input {
     
transform: rotate(calc(90deg * var(--isLTR) * -1));
     
touch-action: pan-x;
   
}
 
}

 
& > input {
   
--thumb-position: 0%;
   
--thumb-transition-duration: .25s;
   
   
padding: var(--track-padding);
   
background: var(--track-color-inactive);
   
inline-size: var(--track-size);
   
block-size: var(--thumb-size);
   
border-radius: var(--track-size);

   
appearance: none;
   
pointer-events: none;
   
touch-action: pan-y;
   
border: none;
   
outline-offset: 5px;
   
box-sizing: content-box;

   
flex-shrink: 0;
   
display: grid;
   
align-items: center;
   
grid: [track] 1fr / [track] 1fr;

   
transition: background-color .25s ease;

   
&::before {
     
--highlight-size: 0;

     
content: "";
     
cursor: pointer;
     
pointer-events: auto;
     
grid-area: track;
     
inline-size: var(--thumb-size);
     
block-size: var(--thumb-size);
     
background: var(--thumb-color);
     
box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);
     
border-radius: 50%;
     
transform: translateX(var(--thumb-position));

     
@media (--motionOK) { & {
       
transition:
          transform var
(--thumb-transition-duration) ease,
          box-shadow
.25s ease;
     
}}
   
}

   
&:not(:disabled):hover::before {
     
--highlight-size: .5rem;
   
}

   
&:checked {
     
background: var(--track-color-active);
     
--thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
   
}

   
&:indeterminate {
     
--thumb-position: calc(
        calc
(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
       
* var(--isLTR)
     
);
   
}

   
&:disabled {
     
cursor: not-allowed;
     
--thumb-color: transparent;

     
&::before {
       
cursor: not-allowed;
       
box-shadow: inset 0 0 0 2px hsl(0 0% 100% / 50%);

       
@media (prefers-color-scheme: dark) {
         
box-shadow: inset 0 0 0 2px hsl(0 0% 0% / 50%);
       
}
     
}
   
}
 
}
}
       

       
const elements = document.querySelectorAll('.gui-switch')
const switches = new WeakMap()

const state = {
  activethumb
: null,
  recentlyDragged
: false,
}

const getStyle = (element, prop) =>
  parseInt
(
    window
.getComputedStyle(element)
     
.getPropertyValue(prop))

const getPseudoStyle = (element, prop) =>
  parseInt
(
    window
.getComputedStyle(element, ':before')
     
.getPropertyValue(prop))

const dragInit = event => {
 
if (event.target.disabled) return

  state
.activethumb = event.target
  state
.activethumb.addEventListener('pointermove', dragging)
  state
.activethumb.style.setProperty('--thumb-transition-duration', '0s')
}

const dragging = event => {
 
if (!state.activethumb) return

  let
{thumbsize, bounds, padding} = switches.get(state.activethumb.parentElement)
  let directionality
= getStyle(state.activethumb, '--isLTR')

  let track
= (directionality === -1)
   
? (state.activethumb.clientWidth * -1) + thumbsize + padding
   
: 0

  let pos
= Math.round(event.offsetX - thumbsize / 2)

 
if (pos < bounds.lower) pos = 0
 
if (pos > bounds.upper) pos = bounds.upper

  state
.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
}

const dragEnd = event => {
 
if (!state.activethumb) return

  state
.activethumb.checked = determineChecked()

 
if (state.activethumb.indeterminate)
    state
.activethumb.indeterminate = false

  state
.activethumb.style.removeProperty('--thumb-transition-duration')
  state
.activethumb.style.removeProperty('--thumb-position')
  state
.activethumb.removeEventListener('pointermove', dragging)
  state
.activethumb = null

  padRelease
()
}

const padRelease = () => {
  state
.recentlyDragged = true

  setTimeout
(_ => {
    state
.recentlyDragged = false
 
}, 300)
}

const preventBubbles = event => {
 
if (state.recentlyDragged)
    event
.preventDefault() && event.stopPropagation()
}

const labelClick = event => {
 
if (
    state
.recentlyDragged ||
   
!event.target.classList.contains('gui-switch') ||
    event
.target.querySelector('input').disabled
 
) return

  let checkbox
= event.target.querySelector('input')
  checkbox
.checked = !checkbox.checked
  event
.preventDefault()
}

const determineChecked = () => {
  let
{bounds} = switches.get(state.activethumb.parentElement)
  let curpos
=
   
Math.abs(
      parseInt
(
        state
.activethumb.style.getPropertyValue('--thumb-position')))

 
if (!curpos) {
    curpos
= state.activethumb.checked
     
? bounds.lower
     
: bounds.upper
 
}

 
return curpos >= bounds.middle
}

elements
.forEach(guiswitch => {
  let checkbox
= guiswitch.querySelector('input')
  let thumbsize
= getPseudoStyle(checkbox, 'width')
  let padding
= getStyle(checkbox, 'padding-left') + getStyle(checkbox, 'padding-right')

  checkbox
.addEventListener('pointerdown', dragInit)
  checkbox
.addEventListener('pointerup', dragEnd)
  checkbox
.addEventListener('click', preventBubbles)
  guiswitch
.addEventListener('click', labelClick)

  switches
.set(guiswitch, {
    thumbsize
,
    padding
,
    bounds
: {
      lower
: 0,
      middle
: (checkbox.clientWidth - padding) / 4,
      upper
: checkbox.clientWidth - thumbsize - padding,
   
},
 
})
})

window
.addEventListener('pointerup', event => {
 
if (!state.activethumb) return

  dragEnd
(event)
})