diff --git a/component-generator/constants.js b/component-generator/constants.js index 059807e30f..4c6b3158b7 100644 --- a/component-generator/constants.js +++ b/component-generator/constants.js @@ -23,8 +23,8 @@ exports.COMPONENT_FILES = [ templatePath: path.resolve(__dirname, './templates/README.md'), }, { - targetPath: path.resolve(__dirname, '../src/componentName/componentName.scss'), - templatePath: path.resolve(__dirname, './templates/styles.scss'), + targetPath: path.resolve(__dirname, '../src/componentName/index.scss'), + templatePath: path.resolve(__dirname, './templates/index.scss'), }, { targetPath: path.resolve(__dirname, '../src/componentName/componentName.test.jsx'), diff --git a/component-generator/templates/styles.scss b/component-generator/templates/index.scss similarity index 100% rename from component-generator/templates/styles.scss rename to component-generator/templates/index.scss diff --git a/component-generator/utils.js b/component-generator/utils.js index 1b43376264..5d4b4c2585 100644 --- a/component-generator/utils.js +++ b/component-generator/utils.js @@ -81,7 +81,7 @@ function addComponentToExports(componentName) { ); fs.appendFileSync( path.resolve(__dirname, '../src/index.scss'), - `@import './${componentName}/${componentName}.scss';\n`, + `@import "./${componentName}";\n`, ); } diff --git a/src/Chip/README.md b/src/Chip/README.md index 9984b440fd..6497f9e7d3 100644 --- a/src/Chip/README.md +++ b/src/Chip/README.md @@ -47,63 +47,3 @@ notes: | ``` - -## `Chip` Carousel - -```jsx live - - - {({ - setOverflowRef, - isScrolledToStart, - isScrolledToEnd, - scrollToPrevious, - scrollToNext, - }) => ( - <> -
- - -
-
- - New - New - New - New - New - New - New - New - New - New - New - New - New - New - New - New - New - New - New - New - -
- - )} -
-
-``` diff --git a/src/ChipCarousel/ChipCarousel.test.jsx b/src/ChipCarousel/ChipCarousel.test.jsx new file mode 100644 index 0000000000..a7e7229951 --- /dev/null +++ b/src/ChipCarousel/ChipCarousel.test.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from 'react-intl'; +import ChipCarousel from '.'; + +const items = [ + { + props: { + onClick: jest.fn(), + children: 'Item 1', + 'data-testid': 'chip', + }, + }, + { + props: { + onClick: jest.fn(), + children: 'Item 2', + 'data-testid': 'chip', + }, + }, + { + props: { + onClick: jest.fn(), + children: 'Item 3', + 'data-testid': 'chip', + }, + }, + { + props: { + onClick: jest.fn(), + 'data-testid': 'chip', + }, + }, + 'Test string', +]; + +const ariaLabel = 'Test aria label'; +function TestingChipCarousel(props) { + return ( + + + + ); +} + +describe('', () => { + it('should render the carousel correctly', () => { + render(); + + const carousel = screen.getByTestId('chip-carousel'); + expect(carousel).toBeTruthy(); + + const chipItems = screen.queryAllByTestId('chip'); + expect(chipItems).toHaveLength(items.length - 2); + for (let i = 0; i < chipItems.length - 2; i++) { + expect(chipItems[i].textContent).toBe(items[i].props.children); + } + }); + + it('should call onClick when a chip item is clicked', async () => { + render(); + + const chipItems = screen.getByTestId('chip-carousel'); + for (let i = 0; i < chipItems.length; i++) { + // eslint-disable-next-line no-await-in-loop + await userEvent.click(chipItems[i]); + expect(items[i].props.onClick).toHaveBeenCalledTimes(1); + } + }); +}); diff --git a/src/ChipCarousel/README.md b/src/ChipCarousel/README.md new file mode 100644 index 0000000000..2c07f59828 --- /dev/null +++ b/src/ChipCarousel/README.md @@ -0,0 +1,84 @@ +--- +title: 'ChipCarousel' +type: 'component' +components: +- ChipCarousel +categories: +- Content +status: 'New' +designStatus: 'Done' +devStatus: 'Done' +notes: | +--- + +The ``ChipCarousel`` component creates a scrollable horizontal block of chips with buttons for navigating to the previous and next set of chips. + +## Basic Usage + +```jsx live +() => { + const MAX_PERCENTAGE = 100; + const MAX_FIXED = 1000; + const [offset, setOffset] = useState(50); + const [offsetType, setOffsetType] = useState('fixed'); + const [gap, setGap] = useState(3) + + const handleChangeOffsetType = (value) => { + const currentMax = offsetType === 'percentage' ? MAX_PERCENTAGE : MAX_FIXED + const newMax = value === 'percentage' ? MAX_PERCENTAGE : MAX_FIXED + const ration = offset / currentMax + const newOffset = Math.floor(newMax * ration) + setOffset(newOffset); + setOffsetType(value); + } + + return ( + <> + {/* start example form block */} + + {/* end example form block */} + ( + console.log(`Chip #${index + 1} clicked`)} + > + Chip #{index + 1} + + ) + )} + /> + + ) +} +``` diff --git a/src/ChipCarousel/_variables.scss b/src/ChipCarousel/_variables.scss new file mode 100644 index 0000000000..e033dc2fcd --- /dev/null +++ b/src/ChipCarousel/_variables.scss @@ -0,0 +1 @@ +$chip-carousel-controls-top-offset: -3px !default; diff --git a/src/ChipCarousel/index.scss b/src/ChipCarousel/index.scss new file mode 100644 index 0000000000..744acf9dea --- /dev/null +++ b/src/ChipCarousel/index.scss @@ -0,0 +1,32 @@ +@import "variables"; + +.pgn__chip-carousel { + position: relative; + + .pgn__overflow-scroll-overflow-container { + --pgn-overflow-scroll-opacity-mask-transparent: rgba(0, 0, 0, 0); + } + + @each $level, $space in $spacers { + &.pgn__chip-carousel-gap__#{$level} { + .pgn__overflow-scroll-overflow-container { + column-gap: $space; + } + } + } + + .pgn__chip-carousel__right-control, + .pgn__chip-carousel__left-control { + position: absolute; + z-index: 2; + top: $chip-carousel-controls-top-offset; + } + + .pgn__chip-carousel__right-control { + right: 0; + } + + .pgn__chip-carousel__left-control { + left: 0; + } +} diff --git a/src/ChipCarousel/index.tsx b/src/ChipCarousel/index.tsx new file mode 100644 index 0000000000..9b8d458ce5 --- /dev/null +++ b/src/ChipCarousel/index.tsx @@ -0,0 +1,159 @@ +import React, { ForwardedRef } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; +import classNames from 'classnames'; +// @ts-ignore +import { OverflowScroll, OverflowScrollContext } from '../OverflowScroll'; +// @ts-ignore +import IconButton from '../IconButton'; +// @ts-ignore +import Icon from '../Icon'; +// @ts-ignore +import { ArrowForward, ArrowBack } from '../../icons'; +// @ts-ignore +import messages from './messages'; +import Chip from '../Chip'; + +export interface OverflowScrollContextProps { + setOverflowRef: () => void, + isScrolledToStart: boolean, + isScrolledToEnd: boolean, + scrollToPrevious: () => void, + scrollToNext: () => void, +} + +export interface ChipCarouselProps { + className?: string; + items: Array; + ariaLabel: string; + disableOpacityMasks?: boolean; + onScrollPrevious?: () => void; + onScrollNext?: () => void; + canScrollHorizontal?: boolean; + offset?: number | string; + offsetType?: 'percentage' | 'fixed'; + gap?: number; +} + +const ChipCarousel = React.forwardRef(({ + className, + items, + ariaLabel, + disableOpacityMasks, + onScrollPrevious, + onScrollNext, + canScrollHorizontal = false, + offset = 120, + offsetType = 'fixed', + gap, + ...props +}: ChipCarouselProps, ref: ForwardedRef) => { + const intl = useIntl(); + + return ( +
+ + + {({ + setOverflowRef, + isScrolledToStart, + isScrolledToEnd, + scrollToPrevious, + scrollToNext, + }: OverflowScrollContextProps) => ( + <> + <> + {!isScrolledToStart && ( + + )} + {!isScrolledToEnd && ( + + )} + +
+ + {items?.map(item => { + const { children } = item?.props || {}; + if (!children) { + return null; + } + return React.createElement(Chip, item.props); + })} + +
+ + )} +
+
+
+ ); +}); + +ChipCarousel.propTypes = { + /** Text describing the ChipCarousel for screen readers. */ + ariaLabel: PropTypes.string.isRequired, + /** Specifies class name for the ChipCarousel. */ + className: PropTypes.string, + /** Specifies array of `Chip` elements to be rendered inside the carousel. */ + // @ts-ignore + items: PropTypes.arrayOf(PropTypes.element).isRequired, + /** Whether the default opacity masks should be shown at the start/end, if applicable. */ + disableOpacityMasks: PropTypes.bool, + /** Callback function for when the user scrolls to the previous element. */ + onScrollPrevious: PropTypes.func, + /** Callback function for when the user scrolls to the next element. */ + onScrollNext: PropTypes.func, + /** Whether users can scroll within the overflow container. */ + canScrollHorizontal: PropTypes.bool, + /** A value specifying the distance the scroll should move. */ + offset: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** Type of offset value (percentage or fixed). */ + offsetType: PropTypes.oneOf(['percentage', 'fixed']), + /** + * Specifies inner space between children blocks. + * + * Valid values are based on `the spacing classes`: + * `0, 0.5, ... 6`. + */ + gap: PropTypes.number, +}; + +ChipCarousel.defaultProps = { + className: undefined, + disableOpacityMasks: undefined, + onScrollPrevious: undefined, + onScrollNext: undefined, + canScrollHorizontal: false, + offset: 120, + offsetType: 'fixed', + gap: 3, +}; + +export default ChipCarousel; diff --git a/src/ChipCarousel/messages.js b/src/ChipCarousel/messages.js new file mode 100644 index 0000000000..d1931edb5b --- /dev/null +++ b/src/ChipCarousel/messages.js @@ -0,0 +1,16 @@ +import { defineMessages } from 'react-intl'; + +const messages = defineMessages({ + scrollToPrevious: { + id: 'pgn.ChipCarousel.scrollToPrevious', + defaultMessage: 'Scroll to previous', + description: 'Accessibility text describing the action for navigating a scrollable carousel to the previous element', + }, + scrollToNext: { + id: 'pgn.ChipCarousel.scrollToNext', + defaultMessage: 'Scroll to next', + description: 'Accessibility text describing the action for navigating a scrollable carousel to the next element', + }, +}); + +export default messages; diff --git a/src/Icon/index.d.ts b/src/Icon/index.d.ts new file mode 100644 index 0000000000..eb8c52ce6f --- /dev/null +++ b/src/Icon/index.d.ts @@ -0,0 +1,18 @@ +import React from 'react'; + +export interface IconProps { + src?: React.ReactElement | Function; + svgAttrs?: { + 'aria-label'?: string; + 'aria-labelledby'?: string; + }; + id?: string; + size?: 'xs' | 'sm' | 'md' | 'lg'; + className?: string; + hidden?: boolean; + screenReaderText?: React.ReactNode; +} + +declare const Icon: React.FC; + +export default Icon; diff --git a/src/OverflowScroll/OverflowScroll.jsx b/src/OverflowScroll/OverflowScroll.jsx index ec47b5ab30..b324beaaac 100644 --- a/src/OverflowScroll/OverflowScroll.jsx +++ b/src/OverflowScroll/OverflowScroll.jsx @@ -84,7 +84,9 @@ OverflowScroll.propTypes = { onScrollPrevious: PropTypes.func, /** Callback function for when the user scrolls to the next element. */ onScrollNext: PropTypes.func, - offset: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + /** A value specifying the distance the scroll should move. */ + offset: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + /** Type of offset value (percentage or fixed). */ offsetType: PropTypes.oneOf(['percentage', 'fixed']), }; diff --git a/src/index.js b/src/index.js index f41436a91f..a25410d8f9 100644 --- a/src/index.js +++ b/src/index.js @@ -23,6 +23,7 @@ export { export { default as CheckBox } from './CheckBox'; export { default as CheckBoxGroup } from './CheckBoxGroup'; export { default as Chip, CHIP_PGN_CLASS } from './Chip'; +export { default as ChipCarousel } from './ChipCarousel'; export { default as CloseButton } from './CloseButton'; export { default as Container } from './Container'; export { default as Layout, Col, Row } from './Layout'; diff --git a/src/index.scss b/src/index.scss index 51872805a6..41a8e68e6c 100644 --- a/src/index.scss +++ b/src/index.scss @@ -11,6 +11,7 @@ @import "./Collapsible"; @import "./CloseButton"; @import "./Chip"; +@import "./ChipCarousel"; @import "./Code"; @import "./Dropdown"; @import "./Fieldset";