diff --git a/packages/spindle-hooks/src/index.ts b/packages/spindle-hooks/src/index.ts new file mode 100644 index 000000000..b6443f2bc --- /dev/null +++ b/packages/spindle-hooks/src/index.ts @@ -0,0 +1 @@ +export { useCarousel } from './useCarousel'; diff --git a/packages/spindle-hooks/src/useCarousel/index.ts b/packages/spindle-hooks/src/useCarousel/index.ts new file mode 100644 index 000000000..b6443f2bc --- /dev/null +++ b/packages/spindle-hooks/src/useCarousel/index.ts @@ -0,0 +1 @@ +export { useCarousel } from './useCarousel'; diff --git a/packages/spindle-hooks/src/useCarousel/useAutoSlide.ts b/packages/spindle-hooks/src/useCarousel/useAutoSlide.ts new file mode 100644 index 000000000..1bf839d81 --- /dev/null +++ b/packages/spindle-hooks/src/useCarousel/useAutoSlide.ts @@ -0,0 +1,58 @@ +import { useCallback, useEffect, useState } from 'react'; + +const AUTO_SLIDE_SPEED = 4000; // ms + +type Payload = { + onTimeOut: () => void; + shouldAutoPlaying?: boolean; +}; + +export function useAutoSlide({ onTimeOut, shouldAutoPlaying = true }: Payload) { + const [isAutoPlaying, setIsAutoPlaying] = useState(shouldAutoPlaying); + const [timeoutId, setTimeoutId] = useState(null); + + const resetTimeOut = useCallback(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }, [timeoutId]); + + const activateAutoSlide = () => { + resetTimeOut(); + + const newTimeoutId = setTimeout(() => { + onTimeOut(); + }, AUTO_SLIDE_SPEED); + + setTimeoutId(newTimeoutId); + }; + + const resetAutoSlide = () => { + if (isAutoPlaying) { + activateAutoSlide(); + } + }; + + const toggleAutoPlay = () => { + resetTimeOut(); + + if (!isAutoPlaying) { + activateAutoSlide(); + } + setIsAutoPlaying((prev) => !prev); + }; + + useEffect(() => { + if (shouldAutoPlaying) { + activateAutoSlide(); + } + }, []); + + return { + isAutoPlaying, + setIsAutoPlaying, + resetAutoSlide, + resetTimeOut, + toggleAutoPlay, + }; +} diff --git a/packages/spindle-hooks/src/useCarousel/useCarousel.stories.mdx b/packages/spindle-hooks/src/useCarousel/useCarousel.stories.mdx new file mode 100644 index 000000000..a9d667087 --- /dev/null +++ b/packages/spindle-hooks/src/useCarousel/useCarousel.stories.mdx @@ -0,0 +1,350 @@ +import { useCallback } from 'react'; +import { Description, Meta, Story, Source } from '@storybook/addon-docs/blocks'; +import { actions } from '@storybook/addon-actions'; +import { useCarousel } from './useCarousel'; + +export const ITEM_LINK_CLASS_NAME = 'js-auto-play-carousel-item-link'; + +export const carouselList = [ + { + title: '1. 生きたコンテンツをつむぐ', + imageUrl: + 'https://images.microcms-assets.io/assets/24995dc41d5c40808fe4a9e3f6fb2b20/e2526e7bfa494168a2e547cfe55ac89f/top_mv.jpg?w=640&h=336&fit=crop&fm=webp&q=85', + link: 'https://about.ameba.jp/', + }, + { + title: '2. 長期間続けるためのシステム開発', + imageUrl: + 'https://images.microcms-assets.io/assets/24995dc41d5c40808fe4a9e3f6fb2b20/8582f4d2842741f78a7d8496019a2be2/team_article_003_cover.png?w=640&h=336&fm=webp&q=85', + link: 'https://about.ameba.jp/contents/czriiu_tv/', + }, + { + title: '3. 「Spindle」成立の軌跡', + imageUrl: + 'https://images.microcms-assets.io/assets/24995dc41d5c40808fe4a9e3f6fb2b20/eb868ea0d6af41aaaff3091b7c1d4cfb/team_article_002_cover.png?w=640&h=336&fm=webp&q=85', + link: 'https://about.ameba.jp/contents/ed88wdx61mf1/', + }, + { + title: '4. Amebaでブログを書こう', + imageUrl: 'https://stat100.ameba.jp/ameblo/sp/img/amebloJp/ogp_image.png', + link: 'https://ameblo.jp/', + }, + { + title: '5. 「人」や「文化」をご紹介', + imageUrl: + 'https://images.microcms-assets.io/assets/24995dc41d5c40808fe4a9e3f6fb2b20/2ed4baaeb1b24a0096bef49b087fbe38/team_article_004__cover%402x.jpg?w=640&h=336&fit=crop&fm=webp&q=85', + link: 'https://about.ameba.jp/team/', + }, +]; + +export const HeroCarouselItem = ({ + carouselItem, + isLinkClicked, + itemLinkClassName, +}) => { + const handleLinkClick = useCallback( + (e) => { + if (!isLinkClicked) { + e.preventDefault(); + } + }, + [isLinkClicked], + ); + return ( +
  • + + + + +
    +

    {carouselItem.title}

    +
    +
    +
  • + ); +}; + +export const HeroCarousel = () => { + const { + handleSlideToPrev, + handleSlideToNext, + handleMouseEnter, + handleMouseDown, + handleMouseLeave, + handleTouchStart, + handleTransitionEnd, + isAutoPlaying, + isLinkClicked, + itemsToRender, + listRef, + listStyles, + toggleAutoPlay, + handleFocus, + handleBlur, + } = useCarousel({ + items: carouselList, + itemLinkClassName: ITEM_LINK_CLASS_NAME, + shouldAutoPlaying: false, + displayCount: 3, + }); + if (carouselList.length === 0) { + return null; + } + return ( +
    +
    +
      + {itemsToRender.map((item, index) => ( + + ))} +
    +
    +
    + + + +
    +
    + ); +}; + +# useCarousel + + + +![stability-experiment](https://img.shields.io/badge/stability-experiment-red.svg) + +Carouselの機能をまとめたHooksです。 + + + マークアップ部分のアクセシビリティ周りは担保できないので下の例を参考に実装してください。 + + + + +## Normal + + + + + + + + { + const handleLinkClick = useCallback( + (e) => { + if (!isLinkClicked) { + e.preventDefault(); + } + }, + [isLinkClicked], + ); + return ( +
  • + + + + +
    +

    {carouselItem.title}

    +
    +
    +
  • + ); +}; +const HeroCarousel = () => { + const { + handleSlideToPrev, + handleSlideToNext, + handleMouseEnter, + handleMouseDown, + handleMouseLeave, + handleTouchStart, + handleTransitionEnd, + isAutoPlaying, + isLinkClicked, + itemsToRender, + listRef, + listStyles, + toggleAutoPlay, + handleFocus, + handleBlur, + } = useCarousel({ + items: carouselList, + itemLinkClassName: 'js-auto-play-carousel-item-link', + shouldAutoPlaying: false, + displayCount: 3, + }); + if (carouselList.length === 0) { + return null; + } + return ( +
    +
    +
      + {itemsToRender.map((item, index) => ( + + ))} +
    +
    +
    + + + +
    +
    + ); +}; + `} +/> + +## このHooksでやっていること + + + - スライドの自動再生を停止/再開、前後のスライドに移動する機能を持ちます。 + + + - キーボード・フォーカスがスライドに入っているとき、自動再生は止まります。また、スライドにマウス・ホバーしているときも止まります。 + + + - reduced motionが指定された時に、アニメーションを軽減または削除します + diff --git a/packages/spindle-hooks/src/useCarousel/useCarousel.ts b/packages/spindle-hooks/src/useCarousel/useCarousel.ts new file mode 100644 index 000000000..ab5bfab16 --- /dev/null +++ b/packages/spindle-hooks/src/useCarousel/useCarousel.ts @@ -0,0 +1,234 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAutoSlide } from './useAutoSlide'; +import { useCarouselFocus } from './useCarouselFocus'; +import { useSliderMoveEvent } from './useSliderMoveEvent'; +import { useSliderTransition } from './useSliderTransition'; +import { useValueRef } from './useValueRef'; + +type Payload = { + items: Item[]; + itemLinkClassName: string; + shouldAutoPlaying?: boolean; + displayCount?: number; +}; + +const SWIPE_THRESHOLD_X = 20; + +export function useCarousel({ + items, + itemLinkClassName, + shouldAutoPlaying, + displayCount = 5, +}: Payload) { + const { + diffXRef, + diffYRef, + setDiffX, + setDiffY, + setStartX, + setStartY, + } = useSliderMoveEvent(); + const [focusOffset, setFocusOffset] = useState(0); + const [isHovering, setIsHovering] = useState(false); + const [isLinkClicked, setIsLinkClicked] = useState(false); + const [isFocus, setIsFocus] = useState(false); + const isHoveringRef = useValueRef(isHovering); + const slideToNextRef = useValueRef(() => slideToNext()); + const itemCount = useMemo(() => items.length, [items]); + const getIsCopiedItem = useCallback( + (index: number) => { + return index < displayCount || index >= itemCount + displayCount; + }, + [itemCount], + ); + const { + isAutoPlaying, + setIsAutoPlaying, + resetAutoSlide, + resetTimeOut, + toggleAutoPlay, + } = useAutoSlide({ + onTimeOut: slideToNextRef.current, + shouldAutoPlaying, + }); + const { linkRefs, listRef } = useCarouselFocus({ + getIsCopiedItem, + itemLinkClassName, + }); + const isAutoPlayingRef = useValueRef(isAutoPlaying); + const { + currentIndexRef, + currentIndex, + handleTransitionEnd, + listStyles, + disableTransition, + setCurrentIndex, + setDisableAutoFocus, + setDisableTransition, + } = useSliderTransition({ + copyCount: displayCount, + itemCount, + isAutoPlaying, + isFocus, + linkRefs, + }); + + const itemsToRender = useMemo( + // generate copy contents on both ends to make carousel look like looping + () => [ + ...items.slice(-displayCount), + ...items, + ...items.slice(0, displayCount), + ], + [items], + ); + + const slideToNext = (ignoreHover = false) => { + const shouldSlideToNext = + ((!isHoveringRef.current && isAutoPlayingRef.current) || ignoreHover) && + currentIndexRef.current <= itemCount; + + if (shouldSlideToNext) { + setIsFocus(false); + setDisableTransition(false); + setCurrentIndex(currentIndexRef.current + 1); + } + resetAutoSlide(); + }; + + const slideToPrev = () => { + if (currentIndexRef.current >= 0) { + setIsFocus(false); + setDisableTransition(false); + setCurrentIndex(currentIndexRef.current - 1); + } + resetAutoSlide(); + }; + + const handleMouseEnter = () => setIsHovering(true); + + const handleMouseLeave = () => { + setIsHovering(false); + resetAutoSlide(); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + resetTimeOut(); + setStartX(e.clientX); + setStartY(e.clientY); + }; + + const onMouseUp = () => { + if (diffXRef.current > SWIPE_THRESHOLD_X) { + setIsLinkClicked(false); + setIsAutoPlaying(false); + slideToPrev(); + } + if (diffXRef.current < -SWIPE_THRESHOLD_X) { + setIsLinkClicked(false); + setIsAutoPlaying(false); + slideToNext(true); + } + if (diffXRef.current === 0 && diffYRef.current === 0) { + setIsLinkClicked(true); + } + + setStartX(null); + setStartY(null); + setDiffX(0); + setDiffY(0); + }; + + const handleTouchStart = (e: React.TouchEvent) => { + if (!e.touches.length) return; + + const touch = e.touches[0]; + + resetTimeOut(); + handleMouseEnter(); + setStartX(touch.clientX); + setStartY(touch.clientY); + }; + + const onTouchEnd = () => { + setIsHovering(false); + onMouseUp(); + }; + + const handleSlideToPrev = () => { + resetTimeOut(); + setIsAutoPlaying(false); + slideToPrev(); + }; + + const handleSlideToNext = () => { + resetTimeOut(); + setIsAutoPlaying(false); + slideToNext(true); + }; + + const handleFocus = (e: React.FocusEvent) => { + setIsAutoPlaying(false); + setIsFocus(true); + + const { offsetLeft: newFocusOffset } = e.target; + + setFocusOffset(newFocusOffset); + + if (focusOffset === 0 || disableTransition) { + setDisableTransition(false); + return; + } + + setDisableAutoFocus(true); + + if (isHovering && diffXRef.current === 0 && diffYRef.current === 0) { + setIsLinkClicked(true); + return; + } + if (focusOffset > newFocusOffset) { + setDisableTransition(false); + setCurrentIndex(currentIndexRef.current - 1); + } + if (focusOffset < newFocusOffset) { + setDisableTransition(false); + setCurrentIndex(currentIndexRef.current + 1); + } + }; + + const handleBlur = () => { + if (shouldAutoPlaying) { + setIsAutoPlaying(true); + } + setDisableAutoFocus(false); + }; + + useEffect(() => { + document.body.addEventListener('mouseup', onMouseUp); + document.body.addEventListener('touchend', onTouchEnd); + + return () => { + document.body.removeEventListener('mouseup', onMouseUp); + document.body.removeEventListener('touchend', onTouchEnd); + }; + }, []); + + return { + handleSlideToPrev, + handleSlideToNext, + handleMouseEnter, + handleMouseDown, + handleMouseLeave, + handleTouchStart, + handleTransitionEnd, + isAutoPlaying, + isLinkClicked, + itemsToRender, + listRef, + listStyles, + toggleAutoPlay, + handleFocus, + handleBlur, + currentIndex, + }; +} diff --git a/packages/spindle-hooks/src/useCarousel/useCarouselFocus.ts b/packages/spindle-hooks/src/useCarousel/useCarouselFocus.ts new file mode 100644 index 000000000..2190c2c9b --- /dev/null +++ b/packages/spindle-hooks/src/useCarousel/useCarouselFocus.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from 'react'; + +type Payload = { + getIsCopiedItem: (index: number) => boolean; + itemLinkClassName: string; +}; + +export function useCarouselFocus({ + getIsCopiedItem, + itemLinkClassName, +}: Payload) { + const listRef = useRef(null); + const linkRefs = useRef([]); + + useEffect(() => { + if (!listRef.current) return; + + const linkElements = listRef.current.querySelectorAll( + `a.${itemLinkClassName}`, + ); + + if (!linkElements) return; + + // NOTE: use NodeList forEach as IE polyfill + Array.prototype.forEach.call( + linkElements, + (link: HTMLLinkElement, index) => { + linkRefs.current.push(link); + link.setAttribute('tabindex', getIsCopiedItem(index) ? '-1' : '0'); + link.setAttribute( + 'aria-hidden', + getIsCopiedItem(index) ? 'true' : 'false', + ); + }, + ); + }, [getIsCopiedItem, itemLinkClassName]); + + return { + linkRefs, + listRef, + }; +} diff --git a/packages/spindle-hooks/src/useCarousel/useSliderMoveEvent.ts b/packages/spindle-hooks/src/useCarousel/useSliderMoveEvent.ts new file mode 100644 index 000000000..3e184c1c2 --- /dev/null +++ b/packages/spindle-hooks/src/useCarousel/useSliderMoveEvent.ts @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; +import { useValueRef } from './useValueRef'; + +export function useSliderMoveEvent() { + const [diffX, setDiffX] = useState(0); + const [diffY, setDiffY] = useState(0); + const [startX, setStartX] = useState(null); + const [startY, setStartY] = useState(null); + const diffXRef = useValueRef(diffX); + const diffYRef = useValueRef(diffY); + const startXRef = useValueRef(startX); + const startYRef = useValueRef(startY); + + const onMouseMove = (e: MouseEvent) => { + if (startXRef.current === null || startYRef.current === null) return; + + e.preventDefault(); + + setDiffX(e.clientX - startXRef.current); + setDiffY(e.clientY - startYRef.current); + }; + + const onTouchMove = (e: TouchEvent) => { + if ( + startXRef.current === null || + startYRef.current === null || + !e.touches.length + ) { + return; + } + + const touch = e.touches[0]; + + setDiffX(touch.clientX - startXRef.current); + setDiffY(touch.clientY - startYRef.current); + }; + + useEffect(() => { + document.body.addEventListener('mousemove', onMouseMove); + document.body.addEventListener('touchmove', onTouchMove, { + passive: true, + }); + + return () => { + document.body.removeEventListener('mousemove', onMouseMove); + document.body.removeEventListener('touchmove', onTouchMove); + }; + }, []); + + return { + diffXRef, + diffYRef, + setDiffX, + setDiffY, + setStartX, + setStartY, + }; +} diff --git a/packages/spindle-hooks/src/useCarousel/useSliderTransition.ts b/packages/spindle-hooks/src/useCarousel/useSliderTransition.ts new file mode 100644 index 000000000..ff4947555 --- /dev/null +++ b/packages/spindle-hooks/src/useCarousel/useSliderTransition.ts @@ -0,0 +1,57 @@ +import { useMemo, useState, MutableRefObject } from 'react'; +import { useValueRef } from './useValueRef'; + +type Payload = { + copyCount: number; + itemCount: number; + linkRefs: MutableRefObject; + isAutoPlaying: boolean; + isFocus: boolean; +}; + +export function useSliderTransition({ + copyCount, + itemCount, + linkRefs, + isAutoPlaying, + isFocus, +}: Payload) { + const [currentIndex, setCurrentIndex] = useState(0); + const currentIndexRef = useValueRef(currentIndex); + const [disableTransition, setDisableTransition] = useState(false); + const [disableAutoFocus, setDisableAutoFocus] = useState(false); + + const listStyles = useMemo(() => { + const transitionStyle = disableTransition ? { transition: 'none' } : {}; + return { + ...transitionStyle, + transform: `translate3d(${ + -100 * (currentIndex + copyCount) + }%, 0, 0) translateX(0)`, + }; + }, [copyCount, currentIndex, disableTransition]); + + const handleTransitionEnd = () => { + // if reach contents end, rewind without transition to make it look like looping + if (!isFocus) { + setDisableTransition(true); + setCurrentIndex((prev) => (prev + itemCount) % itemCount); + if (!isAutoPlaying && !disableAutoFocus) { + linkRefs.current[ + ((currentIndex + itemCount) % itemCount) + copyCount + ].focus(); + } + } + }; + + return { + currentIndexRef, + currentIndex, + handleTransitionEnd, + listStyles, + setCurrentIndex, + setDisableAutoFocus, + disableTransition, + setDisableTransition, + }; +} diff --git a/packages/spindle-hooks/src/useCarousel/useValueRef.ts b/packages/spindle-hooks/src/useCarousel/useValueRef.ts new file mode 100644 index 000000000..acadb06e5 --- /dev/null +++ b/packages/spindle-hooks/src/useCarousel/useValueRef.ts @@ -0,0 +1,11 @@ +import { useEffect, useRef, MutableRefObject } from 'react'; + +export function useValueRef(value: T): MutableRefObject { + const ref = useRef(value); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref; +}