Skip to content

Commit

Permalink
ToggleButtonGroup and Carousel components created for Sections filter…
Browse files Browse the repository at this point in the history
…s feature (#66)

* ToggleButtonGroup and Carousel components created for Sections filters feature

* resolve comments, fix bug with disabled arrows

* resize update for arrows state added

* return removeEventListener resize

* arrows are now buttons containing svg left and right arrows

* adjust arrows dimensions

* missing exports

* fix styles in carousel and togglebuttongroup

* bump version 1.13.0

* update snapshots
  • Loading branch information
jomcarvajal authored Jan 30, 2025
1 parent b13e71e commit fc9ecbb
Show file tree
Hide file tree
Showing 21 changed files with 1,876 additions and 1,039 deletions.
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openstax/ui-components",
"version": "1.12.0",
"version": "1.13.0",
"license": "MIT",
"source": "./src/index.ts",
"types": "./dist/index.d.ts",
Expand Down Expand Up @@ -69,8 +69,8 @@
"@sentry/react": "^7.48.0",
"classnames": "^2.3.1",
"crypto": "npm:crypto-browserify@^3.12.0",
"react-aria": "^3.34.1",
"react-aria-components": "^1.3.1",
"react-aria": "^3.37.0",
"react-aria-components": "^1.6.0",
"stream": "npm:stream-browserify@^3.0.0"
}
}
20 changes: 20 additions & 0 deletions src/components/Carousel.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Carousel } from './Carousel/index';
import renderer from 'react-test-renderer';

describe('Carousel', () => {

const childrenListWithKeys = [
<button key='1'>Slide 1</button>,
<button key='2'>Slide 2</button>,
<button key='3'>Slide 3</button>,
<button key='4'>Slide 4</button>,
<button key='5'>Slide 5</button>,
];

it('matches snapshot', () => {
const tree = renderer.create(
<Carousel>{childrenListWithKeys}</Carousel>
).toJSON();
expect(tree).toMatchSnapshot();
});
});
60 changes: 60 additions & 0 deletions src/components/Carousel.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React from "react";
import { Carousel } from "./Carousel/index";
import { ToggleButtonGroup } from "./ToggleButtonGroup/index";
import { Key } from "react-aria-components";

const childrenListWithKeys = [
<button key='1'>Slide 1</button>,
<button key='2'>Slide 2</button>,
<button key='3'>Slide 3</button>,
<button key='4'>Slide 4</button>,
<button key='5'>Slide 5</button>,
<button key='6'>Slide 6</button>,
<button key='7'>Slide 7</button>,
<button key='8'>Slide 8</button>,
];

export const Default = () =>
<Carousel>{childrenListWithKeys}</Carousel>;

export const WithToggleButtonGroups = () => {
const [selectedIetms, setSelectedItems] = React.useState(new Set<Key>([]));
const firstSection = [
{ key: 'Section1.1', value: '1.1' },
{ key: 'Section1.2', value: '1.2' },
];

const secondSection = [
{ key: 'Section2.0', value: '2.0' },
{ key: 'Section2.1', value: '2.1' },
{ key: 'Section2.2', value: '2.2' },
{ key: 'Section2.3', value: '2.3' },
];

const FirstToggleGroup =
<ToggleButtonGroup
selectedItems={selectedIetms}
onSelectionChange={setSelectedItems}
>
{firstSection}
</ToggleButtonGroup>

const SecondToggleGroup =
<ToggleButtonGroup
selectedItems={selectedIetms}
onSelectionChange={setSelectedItems}
>
{secondSection}
</ToggleButtonGroup>

return (
<>
<Carousel>
{[FirstToggleGroup, SecondToggleGroup]}
</Carousel>
<p>Current selections: {[...selectedIetms].join(', ')}</p>
</>
);

};

136 changes: 136 additions & 0 deletions src/components/Carousel/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import React from "react";
import {
CarouselContainer,
CarouselOverflow,
CarouselWrapper,
CarouselItem,
StyledArrow,
} from './styles';
import { LeftArrow } from "../svgs/LeftArrow";
import { RightArrow } from "../svgs/RightArrow";

export interface CarouselProps {
children: React.ReactNode[];
}

export const Carousel = ({ children }: CarouselProps) => {
const [currentIndex, setCurrentIndex] = React.useState(0);
const [dragStartX, setDragStartX] = React.useState<number | null>(null);
const [dragX, setDragX] = React.useState(0);
const [dragging, setDragging] = React.useState(false);
const wrapperRef = React.useRef<HTMLDivElement>(null);
const [isLeftArrowDisabled, setIsLeftArrowDisabled] = React.useState(false);
const [isRightArrowDisabled, setIsRightArrowDisabled] = React.useState(false);
const scrollSpeed = 100;

const checkIfRightArrowShouldBeDisabled = React.useCallback(() => {
if (wrapperRef.current) {
const isDisabled = (currentIndex >= ((wrapperRef.current.scrollWidth || 0) - (wrapperRef.current.clientWidth || 0)));
setIsRightArrowDisabled(isDisabled);
}
}, [currentIndex, wrapperRef]);

const checkIfLeftArrowShouldBeDisabled = React.useCallback(() => {
setIsLeftArrowDisabled((currentIndex) === 0);
}, [currentIndex]);

React.useEffect(() => {
if (wrapperRef.current) {
checkIfLeftArrowShouldBeDisabled();
checkIfRightArrowShouldBeDisabled();

window.addEventListener('resize', checkIfLeftArrowShouldBeDisabled);
window.addEventListener('resize', checkIfRightArrowShouldBeDisabled);
}
return () => {
window.removeEventListener('resize', checkIfLeftArrowShouldBeDisabled);
window.removeEventListener('resize', checkIfRightArrowShouldBeDisabled);
};
}, [currentIndex, wrapperRef, checkIfLeftArrowShouldBeDisabled, checkIfRightArrowShouldBeDisabled]);

const handlePrev = () => {
if (wrapperRef.current && !isLeftArrowDisabled) {
const itemWidth = (wrapperRef.current.clientWidth / children.length);
setCurrentIndex((prevIndex) => Math.max(prevIndex - itemWidth - scrollSpeed, 0));
}
};

const handleNext = () => {
if (wrapperRef.current && !isRightArrowDisabled) {
const itemWidth = (wrapperRef.current.clientWidth / children.length);
const maxIndex = (wrapperRef.current.scrollWidth - wrapperRef.current.clientWidth);
setCurrentIndex((prevIndex) => Math.min(prevIndex + itemWidth + scrollSpeed, maxIndex));
}
};

const handleMouseDown = (e: React.MouseEvent) => {
setDragStartX(e.clientX);
setDragging(true); // Allow dragging when move cursor
};

const handleMouseMove = (e: React.MouseEvent) => {
if (!dragging || dragStartX === null) return;
const dragDistance = (e.clientX - dragStartX) / 10;
setDragX(dragDistance);
if (wrapperRef.current) {
wrapperRef.current.style.transition = 'none';
wrapperRef.current.style.transform = `translateX(calc(-${(currentIndex / 10)}rem + ${dragDistance}rem))`;
}
};

const handleMouseUp = () => {
if (!dragging) return;
setDragging(false);
setDragStartX(null);
if (wrapperRef.current) {
const newIndex = currentIndex - (dragX * 10); // *10 convert from rem to px
const maxIndex = (wrapperRef.current.scrollWidth - wrapperRef.current.clientWidth);
const clampedIndex = Math.max(0, Math.min(newIndex, maxIndex));
setCurrentIndex(clampedIndex);
setDragX(0);
wrapperRef.current.style.transition = 'transform 0.3s ease-in-out';
wrapperRef.current.style.transform = `translateX(-${(clampedIndex / 10)}rem)`;
}
};

return (
<CarouselContainer>
<StyledArrow
onClick={handlePrev}
className="left-arrow"
disabled={isLeftArrowDisabled}
>
<LeftArrow width={14} height={14} />
</StyledArrow>
<CarouselOverflow
onPointerDown={handleMouseDown}
onPointerMove={handleMouseMove}
onPointerUp={handleMouseUp}
onPointerLeave={handleMouseUp}
>
<CarouselWrapper
ref={wrapperRef}
// Updates the position of the carousel when the currentIndex changes, avoiding problems when the user uses the keyboard to navigate
style={{ transform: `translateX(calc(-${(currentIndex / 10)}rem + ${dragX}rem))` }}
>
{children.map((child) => {
const key = (child as React.ReactElement).key;
return (
<CarouselItem
key={key ?? null}
>
{child}
</CarouselItem>);
})}
</CarouselWrapper>
</CarouselOverflow>
<StyledArrow
onClick={handleNext}
className="right-arrow"
disabled={isRightArrowDisabled} >
<RightArrow width={14} height={14} />
</StyledArrow>
</CarouselContainer>

);
};
51 changes: 51 additions & 0 deletions src/components/Carousel/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import styled from 'styled-components';
import { palette } from '../../theme/palette';

export const CarouselContainer = styled.div`
position: relative;
margin: 0 4rem;
`;

export const CarouselOverflow = styled.div`
position: inherit;
overflow: hidden;
`;

export const CarouselWrapper = styled.div`
display: flex;
width: auto;
transition: transform 0.3s ease-in-out;
`;

export const CarouselItem = styled.div`
flex: 0 0 auto;
margin-right: 1rem;
`;

export const StyledArrow = styled.button<{ disabled: boolean }>`
position: absolute;
top: 50%;
transform: translateY(-50%);
border: transparent;
background: transparent;
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
opacity: ${({ disabled }) => (disabled ? 0.5 : 1)};
&:hover {
border: 0.1rem solid ${palette.pale};
}
svg {
position: relative;
margin-top: 0.5rem;
}
&.left-arrow {
left: -3rem;
}
&.right-arrow {
right: -3rem;
}
`;

29 changes: 29 additions & 0 deletions src/components/ToggleButtonGroup.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ToggleButtonGroup } from './ToggleButtonGroup/index';
import renderer from 'react-test-renderer';

describe('ToggleButtonGroup', () => {

const childrenListWithKeys = [
{key: 'red', value: 'Red'},
{key: 'green', value: 'Green'},
{key: 'blue', value: 'Blue'},
{key: 'yellow', value: 'Yellow'},
{key: 'orange', value: 'Orange'},
];

it.each`
selectionMode
${'multiple'}
${'single'}
`(`matches snapshot with selectionMode #selectionMode`, ({selectionMode}) => {
const tree = renderer.create(
<ToggleButtonGroup
selectionMode={selectionMode}
selectedItems={new Set(['red'])}
>
{childrenListWithKeys}
</ToggleButtonGroup>
).toJSON();
expect(tree).toMatchSnapshot();
});
});
45 changes: 45 additions & 0 deletions src/components/ToggleButtonGroup.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from "react";
import { ToggleButtonGroup } from "./ToggleButtonGroup/index";
import type { Key } from 'react-aria-components';


const childrenListWithKeys = [
{ key: 'red', value: 'Red' },
{ key: 'green', value: 'Green' },
{ key: 'blue', value: 'Blue' },
{ key: 'yellow', value: 'Yellow' },
{ key: 'orange', value: 'Orange' },
];

export const MultipleSelection = () => {
const [selectedIetms, setSelectedItems] = React.useState(new Set<Key>([]));
return (
<>
<ToggleButtonGroup
selectionMode='multiple'
selectedItems={selectedIetms}
onSelectionChange={setSelectedItems}
>
{childrenListWithKeys}
</ToggleButtonGroup>
<p>Current selections: {[...selectedIetms].join(', ')}</p>
</>

);
};

export const SingleSelection = () => {
const [selectedItems, setSelectedItems] = React.useState(new Set<Key>([]));
return (
<>
<ToggleButtonGroup
selectedItems={selectedItems}
onSelectionChange={setSelectedItems}
>
{childrenListWithKeys}
</ToggleButtonGroup>
<p>Current selections: {[...selectedItems].join(', ')}</p>
</>

);
};
Loading

0 comments on commit fc9ecbb

Please sign in to comment.