diff options
| author | Oskar <oskar@mullvad.net> | 2024-09-23 20:01:03 +0200 |
|---|---|---|
| committer | Oskar <oskar@mullvad.net> | 2024-09-30 09:20:20 +0200 |
| commit | 29956fce815711e2fedba8cdbbd982704f21ee93 (patch) | |
| tree | 16264a1cb6ee03ce0cb57472ac63a6913f70e251 /gui/src | |
| parent | 52153bfa2cfb9bd902ab09822f29902e687d0d5c (diff) | |
| download | mullvadvpn-29956fce815711e2fedba8cdbbd982704f21ee93.tar.xz mullvadvpn-29956fce815711e2fedba8cdbbd982704f21ee93.zip | |
Add PageSlider component
Diffstat (limited to 'gui/src')
| -rw-r--r-- | gui/src/renderer/components/PageSlider.tsx | 221 | ||||
| -rw-r--r-- | gui/src/shared/utils.ts | 2 |
2 files changed, 223 insertions, 0 deletions
diff --git a/gui/src/renderer/components/PageSlider.tsx b/gui/src/renderer/components/PageSlider.tsx new file mode 100644 index 0000000000..c6603664fb --- /dev/null +++ b/gui/src/renderer/components/PageSlider.tsx @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; + +import { colors } from '../../config.json'; +import { NonEmptyArray } from '../../shared/utils'; +import { useStyledRef } from '../lib/utilityHooks'; +import { Icon } from './cell'; + +// The amount of scroll required to switch page. This is compared with the `deltaX` value on the +// onWheel event. +const WHEEL_DELTA_THRESHOLD = 30; + +const StyledPageSliderContainer = styled.div({ + display: 'flex', + flexDirection: 'column', +}); + +const StyledPageSlider = styled.div({ + whiteSpace: 'nowrap', + overflow: 'hidden', +}); + +const StyledPage = styled.div({ + display: 'inline-block', + width: '100%', + whiteSpace: 'normal', + verticalAlign: 'top', +}); + +interface PageSliderProps { + content: NonEmptyArray<React.ReactNode>; +} + +export default function PageSlider(props: PageSliderProps) { + const [page, setPage] = useState(0); + const pageContainerRef = useStyledRef<HTMLDivElement>(); + + const hasNext = page < props.content.length - 1; + const hasPrev = page > 0; + + const next = useCallback(() => { + setPage((page) => Math.min(props.content.length - 1, page + 1)); + }, [props.content.length]); + + const prev = useCallback(() => { + setPage((page) => Math.max(0, page - 1)); + }, []); + + // Go to next or previous page if the user scrolls horizontally. + const onWheel = useCallback( + (event: React.WheelEvent<HTMLDivElement>) => { + if (event.deltaX > WHEEL_DELTA_THRESHOLD) { + next(); + } else if (event.deltaX < -WHEEL_DELTA_THRESHOLD) { + prev(); + } + }, + [next, prev], + ); + + // Scroll to the correct position when the page prop changes. + useEffect(() => { + if (pageContainerRef.current) { + // The page width is the same as the container width. + const width = pageContainerRef.current.offsetWidth; + pageContainerRef.current.scrollTo({ left: width * page, behavior: 'smooth' }); + } + }, [page]); + + // Callback that navigates when left and right arrows are pressed. + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'ArrowLeft') { + prev(); + } else if (event.key === 'ArrowRight') { + next(); + } + }, + [next, prev], + ); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); + + return ( + <StyledPageSliderContainer> + <StyledPageSlider ref={pageContainerRef} onWheel={onWheel}> + {props.content.map((page, i) => ( + <StyledPage key={`page-${i}`}>{page}</StyledPage> + ))} + </StyledPageSlider> + <Controls + goToPage={setPage} + hasNext={hasNext} + hasPrev={hasPrev} + next={next} + prev={prev} + page={page} + numberOfPages={props.content.length} + /> + </StyledPageSliderContainer> + ); +} + +const StyledControlsContainer = styled.div({ + display: 'flex', + marginTop: '12px', + alignItems: 'center', +}); + +const StyledControlElement = styled.div({ + flex: '1 0 60px', + display: 'flex', +}); + +const StyledArrows = styled(StyledControlElement)({ + display: 'flex', + justifyContent: 'right', + gap: '12px', +}); + +const StyledPageIndicators = styled(StyledControlElement)({ + display: 'flex', + flexGrow: 2, + justifyContent: 'center', +}); + +const StyledTransparentButton = styled.button({ + border: 'none', + background: 'transparent', + padding: '4px', + margin: 0, +}); + +const StyledPageIndicator = styled.div<{ $current: boolean }>((props) => ({ + width: '8px', + height: '8px', + borderRadius: '50%', + backgroundColor: props.$current ? colors.white80 : colors.white40, + + [`${StyledTransparentButton}:hover &&`]: { + backgroundColor: colors.white80, + }, +})); + +const StyledArrow = styled(Icon)((props) => ({ + backgroundColor: props.disabled ? colors.white20 : props.tintColor, + + [`${StyledTransparentButton}:hover &&`]: { + backgroundColor: props.disabled ? colors.white20 : props.tintHoverColor, + }, +})); + +const StyledLeftArrow = styled(StyledArrow)({ + transform: 'scaleX(-100%)', +}); + +interface ControlsProps { + page: number; + numberOfPages: number; + hasNext: boolean; + hasPrev: boolean; + next: () => void; + prev: () => void; + goToPage: (page: number) => void; +} + +function Controls(props: ControlsProps) { + return ( + <StyledControlsContainer> + <StyledControlElement>{/* spacer to make page indicators centered */}</StyledControlElement> + <StyledPageIndicators> + {[...Array(props.numberOfPages)].map((_, i) => ( + <PageIndicator key={i} current={i === props.page} page={i} goToPage={props.goToPage} /> + ))} + </StyledPageIndicators> + <StyledArrows> + <StyledTransparentButton onClick={props.prev}> + <StyledLeftArrow + disabled={!props.hasPrev} + height={12} + width={7} + source="icon-chevron" + tintColor={colors.white} + tintHoverColor={colors.white60} + /> + </StyledTransparentButton> + <StyledTransparentButton onClick={props.next}> + <StyledArrow + disabled={!props.hasNext} + height={12} + width={7} + source="icon-chevron" + tintColor={colors.white} + tintHoverColor={colors.white60} + /> + </StyledTransparentButton> + </StyledArrows> + </StyledControlsContainer> + ); +} + +interface PageIndicatorProps { + page: number; + goToPage: (page: number) => void; + current: boolean; +} + +function PageIndicator(props: PageIndicatorProps) { + const onClick = useCallback(() => { + props.goToPage(props.page); + }, [props.goToPage, props.page]); + + return ( + <StyledTransparentButton onClick={onClick}> + <StyledPageIndicator $current={props.current} /> + </StyledTransparentButton> + ); +} diff --git a/gui/src/shared/utils.ts b/gui/src/shared/utils.ts index 24984e4412..042c56385a 100644 --- a/gui/src/shared/utils.ts +++ b/gui/src/shared/utils.ts @@ -1,3 +1,5 @@ +export type NonEmptyArray<T> = [T, ...T[]]; + export function hasValue<T>(value: T): value is NonNullable<T> { return value !== undefined && value !== null; } |
