Skip to content

Commit

Permalink
feat(ui-components): introduce Carousel (#6289)
Browse files Browse the repository at this point in the history
  • Loading branch information
dhayab authored Jul 24, 2024
1 parent 0f87fc3 commit 6085d11
Show file tree
Hide file tree
Showing 9 changed files with 575 additions and 10 deletions.
17 changes: 11 additions & 6 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -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 = {};
Expand All @@ -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',
Expand Down
12 changes: 11 additions & 1 deletion packages/instantsearch-ui-components/__tests__/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ function delint(sourceFile: ts.SourceFile) {
message: string;
}> = [];

let exportsValidFunctionName = false;
delintNode(sourceFile);

function delintNode(node: ts.Node) {
Expand All @@ -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
Expand All @@ -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.`
Expand All @@ -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.`
Expand All @@ -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`
Expand All @@ -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;
Expand All @@ -92,7 +102,7 @@ function delint(sourceFile: ts.SourceFile) {
});
}

return errors;
return !exportsValidFunctionName ? errors : [];
}

const files = fs
Expand Down
4 changes: 2 additions & 2 deletions packages/instantsearch-ui-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
234 changes: 234 additions & 0 deletions packages/instantsearch-ui-components/src/components/Carousel.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = Record<string, unknown>
> = ComponentProps<'div'> & {
listRef: MutableRef<HTMLOListElement | null>;
nextButtonRef: MutableRef<HTMLButtonElement | null>;
previousButtonRef: MutableRef<HTMLButtonElement | null>;
carouselIdRef: MutableRef<string>;
items: Array<RecordWithObjectID<TObject>>;
itemComponent: (
props: RecommendItemComponentProps<RecordWithObjectID<TObject>> &
TComponentProps
) => JSX.Element;
classNames?: Partial<CarouselClassNames>;
translations?: Partial<CarouselTranslations>;
};

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<TObject extends RecordWithObjectID>(
userProps: CarouselProps<TObject>
) {
const {
listRef,
nextButtonRef,
previousButtonRef,
carouselIdRef,
classNames = {},
itemComponent: ItemComponent,
items,
translations: userTranslations,
...props
} = userProps;

const translations: Required<CarouselTranslations> = {
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 (
<div {...props} className={cx(cssClasses.root)}>
<button
ref={previousButtonRef}
title={translations.previousButtonTitle}
aria-label={translations.previousButtonLabel}
hidden
aria-controls={carouselIdRef.current}
className={cx(cssClasses.navigation, cssClasses.navigationPrevious)}
onClick={(event) => {
event.preventDefault();
scrollLeft();
}}
>
<svg width="8" height="16" viewBox="0 0 8 16" fill="none">
<path
fillRule="evenodd"
clipRule="evenodd"
fill="currentColor"
d="M7.13809 0.744078C7.39844 1.06951 7.39844 1.59715 7.13809 1.92259L2.27616 8L7.13809 14.0774C7.39844 14.4028 7.39844 14.9305 7.13809 15.2559C6.87774 15.5814 6.45563 15.5814 6.19528 15.2559L0.861949 8.58926C0.6016 8.26382 0.6016 7.73618 0.861949 7.41074L6.19528 0.744078C6.45563 0.418641 6.87774 0.418641 7.13809 0.744078Z"
/>
</svg>
</button>

<ol
className={cx(cssClasses.list)}
ref={listRef}
tabIndex={0}
id={carouselIdRef.current}
aria-roledescription="carousel"
aria-label={translations.listLabel}
aria-live="polite"
onScroll={updateNavigationButtonsProps}
onKeyDown={(event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollLeft();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollRight();
}
}}
>
{items.map((item, index) => (
<li
key={item.objectID}
className={cx(cssClasses.item)}
aria-roledescription="slide"
aria-label={`${index + 1} of ${items.length}`}
>
<ItemComponent item={item} />
</li>
))}
</ol>

<button
ref={nextButtonRef}
title={translations.nextButtonTitle}
aria-label={translations.nextButtonLabel}
aria-controls={carouselIdRef.current}
className={cx(cssClasses.navigation, cssClasses.navigationNext)}
onClick={(event) => {
event.preventDefault();
scrollRight();
}}
>
<svg width="8" height="16" viewBox="0 0 8 16" fill="none">
<path
fillRule="evenodd"
clipRule="evenodd"
fill="currentColor"
d="M0.861908 15.2559C0.601559 14.9305 0.601559 14.4028 0.861908 14.0774L5.72384 8L0.861908 1.92259C0.601559 1.59715 0.601559 1.06952 0.861908 0.744079C1.12226 0.418642 1.54437 0.418642 1.80472 0.744079L7.13805 7.41074C7.3984 7.73618 7.3984 8.26382 7.13805 8.58926L1.80472 15.2559C1.54437 15.5814 1.12226 15.5814 0.861908 15.2559Z"
/>
</svg>
</button>
</div>
);
};
}
Loading

0 comments on commit 6085d11

Please sign in to comment.