Skip to content

Commit

Permalink
feat: carousel
Browse files Browse the repository at this point in the history
  • Loading branch information
ubermanu committed Jun 2, 2023
1 parent b43ef5a commit 37cc521
Show file tree
Hide file tree
Showing 7 changed files with 417 additions and 0 deletions.
263 changes: 263 additions & 0 deletions src/lib/components/Carousel/carousel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { delegateEventListeners } from '$lib/helpers/events.js'
import { traveller } from '$lib/helpers/traveller.js'
import { generateId } from '$lib/helpers/uuid.js'
import { activeElement } from '$lib/stores/activeElement.js'
import { reducedMotion } from '$lib/stores/reducedMotion.js'
import type { Action } from 'svelte/action'
import { derived, get, readable, readonly, writable } from 'svelte/store'

export type CarouselConfig = {
/** The orientation of the carousel */
orientation?: 'horizontal' | 'vertical'

/**
* Whether the carousel should loop back to the beginning when it reaches the
* end
*/
loop?: boolean

/** Whether the carousel should autoplay */
autoplay?: boolean

/** The interval at which the carousel goes to the next slide (default: 5s) */
interval?: number
}

export type Carousel = ReturnType<typeof createCarousel>

export const createCarousel = (config?: CarouselConfig) => {
const { orientation, loop, autoplay, interval } = { ...config }

const current$ = writable<string>('')
const status$ = writable<'playing' | 'paused'>('paused')
const orientation$ = writable(orientation || 'horizontal')
const loop$ = writable(loop || false)
const autoplay$ = writable(autoplay || false)

const baseId = generateId()
const trackId = `${baseId}-track`

const carouselAttrs = readable({
role: 'group',
'aria-roledescription': 'carousel',
})

const trackAttrs = derived(
[status$, orientation$],
([status, orientation]) => ({
id: trackId,
'aria-orientation': orientation,
'aria-live': status === 'playing' ? 'off' : 'polite',
})
)

// TODO: Set the correct label according to each slide's position
const slideAttrs = derived([current$, status$], ([current, status]) => {
return (key: string) => {
// Set the current slide if it hasn't been set yet
if (!current) {
current$.set(key)
}

return {
role: 'group',
'aria-roledescription': 'slide',
'aria-current': current === key ? 'slide' : undefined,
'data-carousel-slide': key,
// 'aria-label': `X of N slides`,
inert: current === key ? undefined : '',
}
}
})

// TODO: Set the disabled state of the previous button based on the current slide
const previousButtonAttrs = derived([current$, loop$], ([current, loop]) => ({
'aria-controls': trackId,
'aria-label': 'Go to the previous slide',
'data-carousel-prev-slide': '',
// disabled: !loop && current === '1',
}))

// TODO: Set the disabled state of the next button based on the current slide
const nextButtonAttrs = derived([current$, loop$], ([current, loop]) => ({
'aria-controls': trackId,
'aria-label': 'Go to the next slide',
'data-carousel-next-slide': '',
// disabled: !loop && current === '3',
}))

const goToSlide = (key: string) => {
current$.set(key)
}

const getSlideKeys = (): string[] => {
if (!rootNode) {
console.warn('No root node found for carousel')
return []
}

const nodes = traveller(rootNode, '[data-carousel-slide]')
return nodes.all().map((node) => node.dataset.carouselSlide as string)
}

const goToPrevSlide = () => {
const keys = getSlideKeys()

if (!keys.length) {
console.warn('No slides found for carousel')
return
}

const $loop = get(loop$)
const $current = get(current$)

const currentIndex = keys.indexOf($current)
const prevIndex = currentIndex - 1

const prevKey =
$loop && prevIndex < 0 ? keys[keys.length - 1] : keys[prevIndex]

if (prevKey) {
current$.set(prevKey)
}
}

const goToNextSlide = () => {
const keys = getSlideKeys()

if (!keys.length) {
console.warn('No slides found for carousel')
return
}

const $loop = get(loop$)
const $current = get(current$)

const currentIndex = keys.indexOf($current)
const nextIndex = currentIndex + 1

const nextKey =
$loop && nextIndex >= keys.length ? keys[0] : keys[nextIndex]

if (nextKey) {
current$.set(nextKey)
}
}

let timer: number | null = null

const play = () => {
status$.set('playing')
timer = window.setInterval(goToNextSlide, interval || 5000)
}

const pause = () => {
status$.set('paused')
if (timer) {
window.clearInterval(timer)
timer = null
}
}

const toggle = () => {
const $status = get(status$)
if ($status === 'playing') {
pause()
} else {
play()
}
}

let wasPlaying = false

const onCarouselMouseEnter = () => {
wasPlaying = get(status$) === 'playing'
pause()
}

const onCarouselMouseLeave = () => {
if (wasPlaying) {
play()
wasPlaying = false
}
}

let rootNode: HTMLElement | null = null

const useCarousel: Action = (node) => {
rootNode = node

if (autoplay) {
play()
}

node.addEventListener('mouseenter', onCarouselMouseEnter)
node.addEventListener('mouseleave', onCarouselMouseLeave)

const removeListeners = delegateEventListeners(node, {
click: {
'[data-carousel-prev-slide]': () => goToPrevSlide(),
'[data-carousel-next-slide]': () => goToNextSlide(),
},
})

// Automatically disable autoplay when reduced motion is enabled
const unsubscribe = reducedMotion.subscribe((reduced) => {
if (reduced) {
autoplay$.set(false)
pause()
}
})

let focusWithin = false

// Pause the carousel when the user is interacting with it
// TODO: Use the dedicated action
const unsubscribe2 = activeElement.subscribe((element) => {
if (element && node.contains(element)) {
focusWithin = true

if (get(status$) === 'playing') {
pause()
wasPlaying = true
}
} else {
if (focusWithin) {
focusWithin = false

if (wasPlaying) {
play()
wasPlaying = false
}
}
}
})

return {
destroy() {
removeListeners()
unsubscribe()
unsubscribe2()
pause()
rootNode = null
},
}
}

return {
current: readonly(current$),
status: readonly(status$),
carouselAttrs,
trackAttrs,
slideAttrs,
previousButtonAttrs,
nextButtonAttrs,
carousel: useCarousel,
goToSlide,
goToPrevSlide,
goToNextSlide,
play,
pause,
toggle,
}
}
1 change: 1 addition & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './components/Accordion/accordion.js'
export * from './components/Button/button.js'
export * from './components/Calendar/calendar.js'
export * from './components/Carousel/carousel.js'
export * from './components/Checkbox/checkbox.js'
export * from './components/Collapsible/collapsible.js'
export * from './components/Label/label.js'
Expand Down
23 changes: 23 additions & 0 deletions src/lib/stores/reducedMotion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { browser } from '$lib/helpers/environment.js'
import { readable } from 'svelte/store'

/** A store that tracks whether the user has requested reduced motion */
export const reducedMotion = readable(false, (set) => {
if (!browser) {
return () => {}
}

const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')

const setReducedMotion = () => {
set(mediaQuery.matches)
}

setReducedMotion()

mediaQuery.addEventListener('change', setReducedMotion)

return () => {
mediaQuery.removeEventListener('change', setReducedMotion)
}
})
38 changes: 38 additions & 0 deletions src/routes/(docs)/component/carousel/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script lang="ts">
import Page from './example/+page.svelte'
import PageSource from './example/+page.svelte?raw'
import CarouselSource from './example/Carousel.svelte?raw'
import SlideSource from './example/Slide.svelte?raw'
import Snippet from '../../Snippet.svelte'
</script>

<h1>Carousel</h1>

<p>
The Carousel component allows the user to navigate between a set of pages.
</p>

<p>
<a
href="https://www.w3.org/WAI/ARIA/apg/patterns/carousel/"
target="_blank"
rel="noopener"
>
WAI-ARIA Carousel Pattern
</a>
</p>

<h2>Example</h2>

<Page />

<h2>Sources</h2>

<p>Page</p>
<Snippet code={PageSource} />

<p>Carousel</p>
<Snippet code={CarouselSource} />

<p>Slide</p>
<Snippet code={SlideSource} />
16 changes: 16 additions & 0 deletions src/routes/(docs)/component/carousel/example/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script lang="ts">
import Carousel from './Carousel.svelte'
import Slide from './Slide.svelte'
</script>

<Carousel>
<Slide>
<img src="https://picsum.photos/seed/1/800/300" alt />
</Slide>
<Slide>
<img src="https://picsum.photos/seed/2/800/300" alt />
</Slide>
<Slide>
<img src="https://picsum.photos/seed/3/800/300" alt />
</Slide>
</Carousel>
Loading

0 comments on commit 37cc521

Please sign in to comment.