輪轉

這個模式說明如何建立配色、回應式及無障礙輪轉介面元件。

YouTube 影片 · 來自 GitHub 的資料來源

<div class="gui-carousel" carousel-pagination="dots" carousel-controls="auto" carousel-scrollbar="auto"
 
carousel-snapstop="auto" aria-label="Featured Items Carousel">
 
<div class="gui-carousel--scroller">

   
<div class="gui-carousel--snap">
     
<figure class="animate-visibility captioned-image">
       
<img loading="lazy" width="1280" height="720" src="https://picsum.photos/seed/this/1280/720.webp"
         
alt="Blue ocean with a large wave">
       
<figcaption>
         
<a href="#">Learn more about large ocean waves</a>
       
</figcaption>
     
</figure>
   
</div>

   
<div class="gui-carousel--snap">
     
<figure class="animate-visibility captioned-image">
       
<img loading="lazy" width="1280" height="720" src="https://picsum.photos/seed/is/1280/720.webp"
         
alt="Frosty orange desert sunset">
       
<figcaption>
         
<a href="#">Learn more about warm deserts</a>
       
</figcaption>
     
</figure>
   
</div>

   
<div class="gui-carousel--snap">
     
<figure class="animate-visibility captioned-image">
       
<img loading="lazy" width="1280" height="720" src="https://picsum.photos/seed/a/1280/720.webp"
         
alt="African sahara with a giraffe">
       
<figcaption>
         
<a href="#">Learn more about giraffe's</a>
       
</figcaption>
     
</figure>
   
</div>

 
</div>
</div>

       
:where(.gui-carousel) {
 
--_carousel-item-size: 80%;
 
--_carousel-gutters: max(4rem, calc((100% - var(--_carousel-item-size)) / 2));
 
--_carousel-scrollbar-gutter: var(--size-6);
 
--_carousel-pagination-size: var(--size-8);

 
display: grid;
 
grid-template-columns: [carousel-gutter] var(--_carousel-gutters) 1fr [carousel-gutter] var(--_carousel-gutters);
 
grid-template-rows:
   
[carousel-scroller] 1fr
   
[carousel-pagination] var(--_carousel-pagination-size);

 
&:focus-visible {
   
outline-offset: -5px;
 
}

 
/* configuration handlers */
 
&[carousel-pagination="gallery"] {
   
--_carousel-pagination-size: var(--size-10);

   
& > .gui-carousel--pagination {
     
-webkit-mask-image: linear-gradient(to right, #0000 0%, #000 5%, 95%, #000, #0000);
   
}
 
}

 
&[carousel-pagination="none"] {
   
grid-template-rows: [carousel-scroller] 1fr;

   
& > .gui-carousel--pagination {
     
display: none;
   
}
 
}

 
&[carousel-controls="none"] {
   
grid-template-columns: 0 1fr 0;

   
& > .gui-carousel--controls {
     
display: none;
   
}
 
}

 
&[carousel-scrollbar="none"] {
   
--_carousel-pagination-size: var(--size-5);

   
& > .gui-carousel--scroller {
     
scrollbar-width: none;

     
&::-webkit-scrollbar {
       
display: none;
     
}
   
}

   
& > .gui-carousel--pagination {
     
place-self: start center;
   
}
 
}

 
&[carousel-snapstop="always"] .gui-carousel--snap {
   
scroll-snap-stop: always;
 
}
}

:where(.gui-carousel--scroller) {
 
grid-row: 1;
 
grid-column: 1/-1;

 
display: grid;
 
grid-auto-columns: 100%;
 
grid-auto-flow: column;
 
align-items: center;
 
gap: var(--_carousel-gutters);

 
padding-block: var(--size-2) var(--_carousel-scrollbar-gutter);
 
overflow-x: auto;
 
overscroll-behavior-x: contain;
 
scroll-snap-type: x mandatory;
 
scroll-padding-inline: var(--_carousel-gutters);
 
padding-inline: var(--_carousel-gutters);

 
@media (--motionOK) {
   
scroll-behavior: smooth;
 
}
}

:where(.gui-carousel--snap) {
 
scroll-snap-align: center;
}

:where(.gui-carousel--controls) {
 
display: flex;
 
justify-content: space-between;
 
padding-inline: var(--_carousel-gutters);
 
display: contents;

 
& > .gui-carousel--control {
   
margin-block-end: var(--_carousel-scrollbar-gutter);

   
&:not([disabled="true"]):active {
     
transform: scale(.95);
   
}
 
}
}

:where(.gui-carousel--control) {
 
--_shadow-size: 0;
 
--_shadow-highlight-light: hsl(0 0% 50% / 10%);
 
--_shadow-highlight-dark: hsl(0 0% 100% / 20%);
 
--_shadow-highlight: var(--_shadow-highlight-light);

 
grid-row: 1;
 
place-self: center;
 
background: var(--surface-1);
 
color: var(--text-2);
 
inline-size: var(--size-8);
 
aspect-ratio: var(--ratio-square);
 
border-radius: var(--radius-round);
 
box-shadow: 0 0 0 var(--_shadow-size) var(--_shadow-highlight);
 
border: var(--border-size-1) solid transparent;
 
text-indent: 10ch;
 
padding: 0;
 
overflow: hidden;
 
z-index: var(--layer-1);
 
transition: opacity .5s var(--ease-2) .5s;

 
@media (--motionOK) {
   
transition:
      opacity
.5s var(--ease-2) .5s,
      transform
.2s var(--ease-4),
      box-shadow
.2s var(--ease-4),
      outline-offset
145ms var(--ease-2)
   
;
 
}

 
@media (--OSdark) {
   
--_shadow-highlight: var(--_shadow-highlight-dark);
 
}

 
&:hover {
   
--_shadow-size: 6px;
 
}

 
&.--previous {
   
grid-column: 1;
 
}

 
&.--next {
   
grid-column: 3;
 
}

 
@nest [dir="rtl"] & > svg {
   
transform: rotateY(180deg);
 
}

 
&[disabled] {
   
cursor: not-allowed;
   
transition-delay: 0s;

   
& > svg {
     
opacity: .25;
   
}
 
}

 
&:not([disabled]):is(:hover, :focus-visible) {
   
color: var(--link);
 
}

 
&:not([disabled]) svg > path {
   
@media (--motionOK) {
     
--_transform: translateX(var(--_x)) scale(.95);
     
transition: transform .5s var(--ease-squish-3);
     
transform-origin: center center;
   
}
 
}

 
&[aria-label="Next Item"]:not([disabled]):is(:hover, :focus-visible) svg > path {
   
--_x: 2px;
   
transform: var(--_transform);
 
}

 
&[aria-label="Previous Item"]:not([disabled]):is(:hover, :focus-visible) svg > path {
   
--_x: -2px;
   
transform: var(--_transform);
 
}
}

:where(.gui-carousel--pagination) {
 
grid-column: 1/-1;
 
place-self: center;

 
display: grid;
 
grid-auto-flow: column;
 
gap: var(--size-2);

 
max-inline-size: 100%;
 
overflow-x: auto;
 
overscroll-behavior-x: contain;

 
padding-block: var(--size-2);
 
padding-inline: var(--size-4);

 
scrollbar-width: none;

 
&::-webkit-scrollbar {
   
display: none;
 
}

 
@media (--motionOK) {
   
scroll-behavior: smooth;
 
}

 
@nest [carousel-pagination="gallery"] & {
   
margin-block-end: 0;
 
}

 
& > [aria-selected="true"] {
   
background: var(--link);
 
}

 
& > [aria-selected="false"] {
   
transform: scale(.75);
 
}

 
& > button {
   
inline-size: var(--size-3);
   
background-color: var(--surface-4);
   
border: var(--border-size-1) solid transparent;

   
&.--gallery {
     
inline-size: var(--size-fluid-5);
     
border-radius: var(--radius-2);
     
border: none;
     
background-origin: border-box;
     
background-size: cover;
   
}
 
}
}

@keyframes gui-carousel--control-keypress {
 
0%  { outline-offset: 5px }
 
50% { outline-offset: 0; }
}

@keyframes carousel-scrollstart {
  from
{ scroll-snap-align: center }
  to  
{ scroll-snap-align: unset }
}
       

       
import {scrollend} from 'https://cdn.jsdelivr.net/gh/argyleink/scrollyfills@latest/dist/scrollyfills.modern.js'

export default class Carousel {
  constructor
(element) {
   
this.elements = {
      root
:       element,
      scroller
:   element.querySelector('.gui-carousel--scroller'),
      snaps
:      element.querySelectorAll('.gui-carousel--snap'),
      previous
:   null, // generated in #createControl
      next
:       null, // generated in #createControl
      pagination
: null, // generated in #createPagination
   
}

   
this.current = undefined        // set in #initializeState
   
this.hasIntersected = new Set() // holds intersection results used on scrollend

   
this.elements.root.setAttribute('tabindex', -1)
   
this.elements.root.setAttribute('aria-roledescription', 'carousel')

   
this.elements.scroller.setAttribute('role', 'group')
   
this.elements.scroller.setAttribute('aria-label', 'Items Scroller')
   
this.elements.scroller.setAttribute('aria-live', 'Polite')

   
this.#createObservers()
   
this.#createPagination()
   
this.#createControls()
   
this.#initializeState()
   
this.#listen()
   
this.#synchronize()
 
}

 
#synchronize() {
   
for (let observation of this.hasIntersected) {
     
// toggle inert when it's not intersecting
      observation
.target
       
.toggleAttribute('inert', !observation.isIntersecting)

     
// toggle aria-selected on pagination dots
     
const dot = this.elements.pagination
       
.children[this.#getElementIndex(observation.target)]

      dot
.setAttribute('aria-selected', observation.isIntersecting)
      dot
.setAttribute('tabindex', !observation.isIntersecting ? '-1' : '0')

     
// stash the intersecting snap element
     
if (observation.isIntersecting) {
       
this.current = observation.target
       
this.goToElement({
          scrollport
: this.elements.pagination,
          element
: dot,
       
})
     
}
   
}

   
this.#updateControls()
   
this.hasIntersected.clear()
 
}

  goNext
() {
   
const next = this.current.nextElementSibling

   
if (this.current === next)
     
return

   
if (next) {
     
this.goToElement({
        scrollport
: this.elements.scroller,
        element
: next,
     
})
     
this.current = next
   
}
   
else {
      console
.log('at the end')
   
}
 
}

  goPrevious
() {
   
const previous = this.current.previousElementSibling

   
if (this.current === previous)
     
return

   
if (previous) {
     
this.goToElement({
        scrollport
: this.elements.scroller,
        element
: previous,
     
})
     
this.current = previous
   
}
   
else {
      console
.log('at the beginning')
   
}
 
}

  goToElement
({scrollport, element}) {
   
const dir = this.#documentDirection()

   
const delta = Math.abs(scrollport.offsetLeft - element.offsetLeft)
   
const scrollerPadding = parseInt(getComputedStyle(scrollport)['padding-left'])

   
const pos = scrollport.clientWidth / 2 > delta
     
? delta - scrollerPadding
     
: delta + scrollerPadding

    scrollport
.scrollTo(dir === 'ltr' ? pos : pos*-1, 0)
 
}

 
#updateControls() {
   
const {lastElementChild:last, firstElementChild:first} = this.elements.scroller

   
const isAtEnd   = this.current === last
   
const isAtStart = this.current === first

   
// before we possibly disable a button
   
// shift the focus to the complimentary button
   
if (document.activeElement === this.elements.next && isAtEnd)
     
this.elements.previous.focus()
   
else if (document.activeElement === this.elements.previous && isAtStart)
     
this.elements.next.focus()

   
this.elements.next.toggleAttribute('disabled', isAtEnd)
   
this.elements.previous.toggleAttribute('disabled', isAtStart)
 
}

 
#listen() {
   
// observe children intersection
   
for (let item of this.elements.snaps)
     
this.carousel_observer.observe(item)

   
// watch document for removal of this carousel node
   
this.mutation_observer.observe(document, {
      childList
: true,
      subtree
: true,
   
})

   
// scrollend listener for sync
   
this.elements.scroller.addEventListener('scrollend', this.#synchronize.bind(this))
   
this.elements.next.addEventListener('click', this.goNext.bind(this))
   
this.elements.previous.addEventListener('click', this.goPrevious.bind(this))
   
this.elements.pagination.addEventListener('click', this.#handlePaginate.bind(this))
   
this.elements.root.addEventListener('keydown', this.#handleKeydown.bind(this))
 
}

 
#unlisten() {
   
for (let item of this.elements.snaps)
     
this.carousel_observer.unobserve(item)

   
this.mutation_observer.disconnect()

   
this.elements.scroller.removeEventListener('scrollend', this.#synchronize)
   
this.elements.next.removeEventListener('click', this.goNext)
   
this.elements.previous.removeEventListener('click', this.goPrevious)
   
this.elements.pagination.removeEventListener('click', this.#handlePaginate)
   
this.elements.root.removeEventListener('keydown', this.#handleKeydown)
 
}

 
#createObservers() {
   
this.carousel_observer = new IntersectionObserver(observations => {
     
for (let observation of observations) {
       
this.hasIntersected.add(observation)

       
// toggle --in-view class if intersecting or not
        observation
.target.classList
         
.toggle('--in-view', observation.isIntersecting)
     
}
   
}, {
      root
: this.elements.scroller,
      threshold
: .6,
   
})

   
this.mutation_observer = new MutationObserver((mutationList, observer) => {
      mutationList
       
.filter(x => x.removedNodes.length > 0)
       
.forEach(mutation => {
         
[...mutation.removedNodes]
           
.filter(x => x.querySelector('.gui-carousel') === this.elements.root)
           
.forEach(removedEl => {
             
this.#unlisten()
           
})
       
})
   
})
 
}

 
#initializeState() {
   
const startIndex = this.elements.root.hasAttribute('carousel-start')
     
? this.elements.root.getAttribute('carousel-start') - 1
     
: 0

   
this.current = this.elements.snaps[startIndex]
   
this.#handleScrollStart()

   
// each snap target needs a marker for pagination
   
// each snap needs some a11y love
   
this.elements.snaps.forEach((snapChild, index) => {
     
this.hasIntersected.add({
        isIntersecting
: index === 0,
        target
: snapChild,
     
})

     
this.elements.pagination
       
.appendChild(this.#createMarker(snapChild, index))

      snapChild
.setAttribute('aria-label', `${index+1} of ${this.elements.snaps.length}`)
      snapChild
.setAttribute('aria-roledescription', 'item')
   
})
 
}

 
#handleScrollStart() {
   
if (this.elements.root.hasAttribute('carousel-start')) {
     
const itemIndex = this.elements.root.getAttribute('carousel-start')
     
const startElement = this.elements.snaps[itemIndex - 1]

     
this.elements.snaps.forEach(snap =>
        snap
.style.scrollSnapAlign = 'unset')

      startElement
.style.scrollSnapAlign = null
      startElement
.style.animation = 'carousel-scrollstart 1ms'

      startElement
.addEventListener('animationend', e => {
        startElement
.animation = null
       
this.elements.snaps.forEach(snap =>
          snap
.style.scrollSnapAlign = null)
     
}, {once: true})
   
}
 
}

 
#handlePaginate(e) {
   
if (e.target.classList.contains('gui-carousel--pagination'))
     
return

    e
.target.setAttribute('aria-selected', true)
   
const item = this.elements.snaps[this.#getElementIndex(e.target)]

   
this.goToElement({
      scrollport
: this.elements.scroller,
      element
: item,
   
})
 
}

 
#handleKeydown(e) {
   
const dir = this.#documentDirection()
   
const idx = this.#getElementIndex(e.target)

   
switch (e.key) {
     
case 'ArrowRight':
        e
.preventDefault()

       
const next_offset = dir === 'ltr' ? 1 : -1
       
const next_control = dir === 'ltr' ? this.elements.next : this.elements.previous

       
if (e.target.closest('.gui-carousel--pagination'))
         
this.elements
           
.pagination.children[idx + next_offset]
           
?.focus()
       
else {
         
if (document.activeElement === next_control)
           
this.#keypressAnimation(next_control)
          next_control
.focus()
       
}

        dir
=== 'ltr' ? this.goNext() : this.goPrevious()
       
break
     
case 'ArrowLeft':
        e
.preventDefault()

       
const previous_offset = dir === 'ltr' ? -1 : 1
       
const previous_control = dir === 'ltr' ? this.elements.previous : this.elements.next

       
if (e.target.closest('.gui-carousel--pagination'))
         
this.elements
           
.pagination.children[idx + previous_offset]
           
?.focus()
       
else {
         
if (document.activeElement === previous_control)
           
this.#keypressAnimation(previous_control)
          previous_control
.focus()
       
}

        dir
=== 'ltr' ? this.goPrevious() : this.goNext()
       
break
   
}
 
}

 
#getElementIndex(element) {
    let index
= 0
   
while (element = element.previousElementSibling)
      index
++
   
return index
 
}

 
#createPagination() {
    let nav
= document.createElement('nav')
    nav
.className = 'gui-carousel--pagination'
   
this.elements.root.appendChild(nav)

   
this.elements.pagination = nav
 
}

 
#createMarker(item, index) {
   
const markerType = this.elements.root.getAttribute('carousel-pagination')
    index
++ // user facing index shouldn't start at 0

   
if (markerType == 'gallery')
     
return this.#createMarkerGallery({index, type: markerType, item})
   
else
     
return this.#createMarkerDot({index, type: markerType, item})
 
}

 
#createMarkerDot({index, type, item}) {
   
const marker = document.createElement('button')
   
const img = item.querySelector('img')
   
const caption = item.querySelector('figcaption')
    marker
.className = 'gui-carousel--control'
    marker
.type = 'button'
    marker
.role = 'tab'
    marker
.title = `Item ${index}: ${img?.alt || caption?.innerText}`
    marker
.setAttribute('aria-label', img?.alt || caption?.innerText)
    marker
.setAttribute('aria-setsize', this.elements.snaps.length)
    marker
.setAttribute('aria-posinset', index)
    marker
.setAttribute('aria-controls', `carousel-item-${index}`)
   
return marker
 
}

 
#createMarkerGallery({index, type, item}) {
   
const marker = document.createElement('button')
   
const img = item.querySelector('img')
    marker
.style.backgroundImage = `url(${img.src})`
    marker
.className = 'gui-carousel--control --gallery'
    marker
.type = 'button'
    marker
.role = 'tab'
    marker
.title = `Item ${index}: ${img.alt}`
    marker
.setAttribute('aria-label', img.alt)
    marker
.setAttribute('aria-setsize', this.elements.snaps.length)
    marker
.setAttribute('aria-posinset', index)
    marker
.setAttribute('aria-controls', `carousel-item-${index}`)
   
return marker
 
}

 
#createControls() {
    let controls
= document.createElement('div')
    controls
.className = 'gui-carousel--controls'

    let prevBtn
= this.#createControl('previous')
    let nextBtn
= this.#createControl('next')

    controls
.appendChild(prevBtn)
    controls
.appendChild(nextBtn)

   
this.elements.previous = prevBtn
   
this.elements.next = nextBtn
   
this.elements.root.prepend(controls)
 
}

 
#createControl(btnType) {
    let control
= document.createElement('button')
    let userFacingText
= `${btnType.charAt(0).toUpperCase() + btnType.slice(1)} Item`

    control
.type = 'button'
    control
.title = userFacingText
    control
.className = `gui-carousel--control --${btnType}`
    control
.setAttribute('aria-controls', 'gui-carousel--controls')
    control
.setAttribute('aria-label', userFacingText)

    let svg
= document.createElementNS('http://www.w3.org/2000/svg', 'svg')
    svg
.setAttribute('aria-hidden', 'true')
    svg
.setAttribute('viewBox', '0 0 20 20')
    svg
.setAttribute('fill', 'currentColor')

    let path
= document.createElementNS('http://www.w3.org/2000/svg', 'path')
    path
.setAttribute('fill-rule', 'evenodd')
    path
.setAttribute('clip-rule', 'evenodd')

    let previousPath
= 'M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z'
    let nextPath
= 'M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z'

    path
.setAttribute('d', btnType === 'next' ? nextPath : previousPath)

    svg
.appendChild(path)
    control
.appendChild(svg)

   
return control
 
}

 
#keypressAnimation(element) {
    element
.style.animation = 'gui-carousel--control-keypress 145ms var(--ease-2)'
    element
.addEventListener('animationend', e => {
      element
.style.animation = null
   
}, {once: true})
 
}

 
#documentDirection() {
   
return document.firstElementChild.getAttribute('dir') || 'ltr'
 
}
}

document
.querySelectorAll('.gui-carousel').forEach(element => {
 
new Carousel(element)
})