From 6085d110c90ebb3ce51e6afed0157f76da6996a7 Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Wed, 24 Jul 2024 16:41:30 +0200 Subject: [PATCH] feat(ui-components): introduce `Carousel` (#6289) --- babel.config.js | 17 +- .../__tests__/types.test.ts | 12 +- .../instantsearch-ui-components/package.json | 4 +- .../src/components/Carousel.tsx | 234 ++++++++++++++++++ .../components/__tests__/Carousel.test.tsx | 211 ++++++++++++++++ .../src/components/index.ts | 3 +- .../src/types/Renderer.ts | 4 + .../src/themes/_carousel.scss | 98 ++++++++ .../instantsearch.css/src/themes/reset.scss | 2 + 9 files changed, 575 insertions(+), 10 deletions(-) create mode 100644 packages/instantsearch-ui-components/src/components/Carousel.tsx create mode 100644 packages/instantsearch-ui-components/src/components/__tests__/Carousel.test.tsx create mode 100644 packages/instantsearch.css/src/themes/_carousel.scss diff --git a/babel.config.js b/babel.config.js index f33b615d1f..750b6bb718 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,12 +1,16 @@ module.exports = (api) => { const clean = (x) => x.filter(Boolean); - const isTest = api.env('test'); - const isCJS = api.env('cjs'); - const isES = api.env('es'); - const isUMD = api.env('umd'); - const isRollup = api.env('rollup'); - const isParcel = api.env('parcel'); + const env = api.env().split(','); + + const isTest = env.includes('test'); + const isCJS = env.includes('cjs'); + const isES = env.includes('es'); + const isUMD = env.includes('umd'); + const isRollup = env.includes('rollup'); + const isParcel = env.includes('parcel'); + + const disableHoisting = env.includes('disableHoisting'); const modules = isTest || isCJS ? 'commonjs' : false; const targets = {}; @@ -27,6 +31,7 @@ module.exports = (api) => { '@babel/plugin-proposal-private-methods', '@babel/plugin-proposal-private-property-in-object', (isCJS || isES || isUMD || isRollup) && + !disableHoisting && '@babel/plugin-transform-react-constant-elements', 'babel-plugin-transform-react-pure-class-to-function', './scripts/babel/wrap-warning-with-dev-check', diff --git a/packages/instantsearch-ui-components/__tests__/types.test.ts b/packages/instantsearch-ui-components/__tests__/types.test.ts index 2747416e85..8cf699cf1e 100644 --- a/packages/instantsearch-ui-components/__tests__/types.test.ts +++ b/packages/instantsearch-ui-components/__tests__/types.test.ts @@ -14,6 +14,7 @@ function delint(sourceFile: ts.SourceFile) { message: string; }> = []; + let exportsValidFunctionName = false; delintNode(sourceFile); function delintNode(node: ts.Node) { @@ -22,6 +23,7 @@ function delint(sourceFile: ts.SourceFile) { const functionDeclaration = node as ts.FunctionDeclaration; const fileNameSegment = sourceFile.fileName.replace('.d.ts', ''); const componentName = `create${fileNameSegment}Component`; + let hasError = false; if ( functionDeclaration.modifiers?.some( (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword @@ -32,6 +34,7 @@ function delint(sourceFile: ts.SourceFile) { ) { const actualName = functionDeclaration.name?.getText(); if (actualName !== componentName) { + hasError = true; report( node, `Exported component should be named '${componentName}', but was '${actualName}' instead.` @@ -41,6 +44,7 @@ function delint(sourceFile: ts.SourceFile) { const returnType = functionDeclaration.type as ts.FunctionTypeNode; if (returnType.kind !== ts.SyntaxKind.FunctionType) { + hasError = true; report( node, `Exported component's return type should be a function.` @@ -51,6 +55,7 @@ function delint(sourceFile: ts.SourceFile) { returnType.kind === ts.SyntaxKind.FunctionType && returnType.parameters.length !== 1 ) { + hasError = true; report( node, `Exported component's return type should have exactly one parameter` @@ -63,11 +68,16 @@ function delint(sourceFile: ts.SourceFile) { functionDeclaration.type as ts.FunctionTypeNode ).parameters[0].name.getText() !== 'userProps' ) { + hasError = true; report( node, `Exported component's return type should be called 'userProps'.` ); } + + if (!hasError) { + exportsValidFunctionName = true; + } } break; @@ -92,7 +102,7 @@ function delint(sourceFile: ts.SourceFile) { }); } - return errors; + return !exportsValidFunctionName ? errors : []; } const files = fs diff --git a/packages/instantsearch-ui-components/package.json b/packages/instantsearch-ui-components/package.json index 817e1ef2b7..05ea957046 100644 --- a/packages/instantsearch-ui-components/package.json +++ b/packages/instantsearch-ui-components/package.json @@ -39,9 +39,9 @@ "scripts": { "clean": "rm -rf dist", "build": "yarn build:cjs && yarn build:es && yarn build:types", - "build:es:base": "BABEL_ENV=es babel src --root-mode upward --extensions '.js,.ts,.tsx' --out-dir dist/es --ignore '**/__tests__/**/*','**/__mocks__/**/*'", + "build:es:base": "BABEL_ENV=es,disableHoisting babel src --root-mode upward --extensions '.js,.ts,.tsx' --out-dir dist/es --ignore '**/__tests__/**/*','**/__mocks__/**/*'", "build:es": "yarn build:es:base --quiet", - "build:cjs": "BABEL_ENV=cjs babel src --root-mode upward --extensions '.js,.ts,.tsx' --out-dir dist/cjs --ignore '**/__tests__/**/*','**/__mocks__/**/*' --quiet && ../../scripts/prepare-cjs.sh", + "build:cjs": "BABEL_ENV=cjs,disableHoisting babel src --root-mode upward --extensions '.js,.ts,.tsx' --out-dir dist/cjs --ignore '**/__tests__/**/*','**/__mocks__/**/*' --quiet && ../../scripts/prepare-cjs.sh", "build:types": "tsc -p ./tsconfig.declaration.json --outDir ./dist/es", "version": "./scripts/version.cjs", "watch:es": "yarn --silent build:es:base --watch" diff --git a/packages/instantsearch-ui-components/src/components/Carousel.tsx b/packages/instantsearch-ui-components/src/components/Carousel.tsx new file mode 100644 index 0000000000..5ab986c2c6 --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/Carousel.tsx @@ -0,0 +1,234 @@ +/** @jsx createElement */ +import { cx } from '../lib'; + +import type { + ComponentProps, + MutableRef, + RecommendItemComponentProps, + RecordWithObjectID, + Renderer, +} from '../types'; + +export type CarouselProps< + TObject, + TComponentProps extends Record = Record +> = ComponentProps<'div'> & { + listRef: MutableRef; + nextButtonRef: MutableRef; + previousButtonRef: MutableRef; + carouselIdRef: MutableRef; + items: Array>; + itemComponent: ( + props: RecommendItemComponentProps> & + TComponentProps + ) => JSX.Element; + classNames?: Partial; + translations?: Partial; +}; + +export type CarouselClassNames = { + /** + * Class names to apply to the root element + */ + root: string | string[]; + /** + * Class names to apply to the list element + */ + list: string | string[]; + /** + * Class names to apply to each item element + */ + item: string | string[]; + /** + * Class names to apply to both navigation elements + */ + navigation: string | string[]; + /** + * Class names to apply to the next navigation element + */ + navigationNext: string | string[]; + /** + * Class names to apply to the previous navigation element + */ + navigationPrevious: string | string[]; +}; + +export type CarouselTranslations = { + /** + * The label of the next navigation element + */ + nextButtonLabel: string; + /** + * The title of the next navigation element + */ + nextButtonTitle: string; + /** + * The label of the previous navigation element + */ + previousButtonLabel: string; + /** + * The title of the previous navigation element + */ + previousButtonTitle: string; + /** + * The label of the carousel + */ + listLabel: string; +}; + +let lastCarouselId = 0; + +export function generateCarouselId() { + return `ais-Carousel-${lastCarouselId++}`; +} + +export function createCarouselComponent({ createElement }: Renderer) { + return function Carousel( + userProps: CarouselProps + ) { + const { + listRef, + nextButtonRef, + previousButtonRef, + carouselIdRef, + classNames = {}, + itemComponent: ItemComponent, + items, + translations: userTranslations, + ...props + } = userProps; + + const translations: Required = { + listLabel: 'Items', + nextButtonLabel: 'Next', + nextButtonTitle: 'Next', + previousButtonLabel: 'Previous', + previousButtonTitle: 'Previous', + ...userTranslations, + }; + + const cssClasses: CarouselClassNames = { + root: cx('ais-Carousel', classNames.root), + list: cx('ais-Carousel-list', classNames.list), + item: cx('ais-Carousel-item', classNames.item), + navigation: cx('ais-Carousel-navigation', classNames.navigation), + navigationNext: cx( + 'ais-Carousel-navigation--next', + classNames.navigationNext + ), + navigationPrevious: cx( + 'ais-Carousel-navigation--previous', + classNames.navigationPrevious + ), + }; + + function scrollLeft() { + if (listRef.current) { + listRef.current.scrollLeft -= listRef.current.offsetWidth * 0.75; + } + } + + function scrollRight() { + if (listRef.current) { + listRef.current.scrollLeft += listRef.current.offsetWidth * 0.75; + } + } + + function updateNavigationButtonsProps() { + if ( + !listRef.current || + !previousButtonRef.current || + !nextButtonRef.current + ) { + return; + } + + previousButtonRef.current.hidden = listRef.current.scrollLeft <= 0; + nextButtonRef.current.hidden = + listRef.current.scrollLeft + listRef.current.clientWidth >= + listRef.current.scrollWidth; + } + + if (items.length === 0) { + return null; + } + + return ( +
+ + +
    { + if (event.key === 'ArrowLeft') { + event.preventDefault(); + scrollLeft(); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + scrollRight(); + } + }} + > + {items.map((item, index) => ( +
  1. + +
  2. + ))} +
+ + +
+ ); + }; +} diff --git a/packages/instantsearch-ui-components/src/components/__tests__/Carousel.test.tsx b/packages/instantsearch-ui-components/src/components/__tests__/Carousel.test.tsx new file mode 100644 index 0000000000..14f3d4c189 --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/__tests__/Carousel.test.tsx @@ -0,0 +1,211 @@ +/** + * @jest-environment jsdom + */ +/** @jsx createElement */ +import { render } from '@testing-library/preact'; +import { createElement, Fragment } from 'preact'; +import { useRef } from 'preact/hooks'; + +import { createCarouselComponent, generateCarouselId } from '../Carousel'; + +import type { Pragma, RecordWithObjectID } from '../../types'; +import type { CarouselProps } from '../Carousel'; + +const Carousel = createCarouselComponent({ + createElement: createElement as Pragma, + Fragment, +}); + +function CarouselWithRefs( + props: Omit< + CarouselProps, + 'listRef' | 'nextButtonRef' | 'previousButtonRef' | 'carouselIdRef' + > +) { + const carouselRefs: Pick< + CarouselProps, + 'listRef' | 'nextButtonRef' | 'previousButtonRef' | 'carouselIdRef' + > = { + listRef: useRef(null), + nextButtonRef: useRef(null), + previousButtonRef: useRef(null), + carouselIdRef: useRef(generateCarouselId()), + }; + + return ; +} + +const ItemComponent: CarouselProps['itemComponent'] = ({ + item, +}) => (
{item.objectID}
) as JSX.Element; + +describe('Carousel', () => { + test('renders items', () => { + const { container } = render( + + ); + + expect(container).toMatchInlineSnapshot(` +
+ +
+ `); + }); + + test('accepts custom translations', () => { + const { container } = render( + + ); + + expect(container.querySelector('.ais-Carousel-list')).toHaveAttribute( + 'aria-label', + 'Liste' + ); + expect( + container.querySelector('.ais-Carousel-navigation--previous') + ).toHaveAttribute('aria-label', 'Précédent'); + expect( + container.querySelector('.ais-Carousel-navigation--previous') + ).toHaveAccessibleDescription('Produit précédent'); + expect( + container.querySelector('.ais-Carousel-navigation--next') + ).toHaveAttribute('aria-label', 'Suivant'); + expect( + container.querySelector('.ais-Carousel-navigation--next') + ).toHaveAccessibleDescription('Produit suivant'); + }); + + test('forwards `div` props to the root element', () => { + const { container } = render( +