Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"packages/calendar-range",
"packages/calendar-with-skeleton",
"packages/card-image",
"packages/carousel",
"packages/cdn-icon",
"packages/chart",
"packages/checkbox",
Expand Down
12 changes: 9 additions & 3 deletions jest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import fse from 'fs-extra';
import path from 'node:path';
import slash from 'slash';
import { createJsWithTsPreset, pathsToModuleNameMapper } from 'ts-jest';
import { createJsWithTsEsmPreset, pathsToModuleNameMapper } from 'ts-jest';

import { resolveInternal } from './tools/resolve-internal.cjs';

Expand Down Expand Up @@ -40,9 +40,15 @@ const REACT_MARKDOWN_IGNORED_MODULES = [
'html-url-attributes',
];

const IGNORED_MODULES = ['@alfalab/hooks', 'simplebar', 'uuid', ...REACT_MARKDOWN_IGNORED_MODULES];
const IGNORED_MODULES = [
'@alfalab/hooks',
'simplebar',
'uuid',
'swiper',
...REACT_MARKDOWN_IGNORED_MODULES,
];

const tsJestPreset = createJsWithTsPreset({ tsconfig: '<rootDir>/tsconfig.test.json' });
const tsJestPreset = createJsWithTsEsmPreset({ tsconfig: '<rootDir>/tsconfig.test.json' });

/**
* @type {import('ts-jest').JestConfigWithTsJest['projects']}
Expand Down
1 change: 1 addition & 0 deletions packages/carousel/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @alfalab/core-components-carousel
30 changes: 30 additions & 0 deletions packages/carousel/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@alfalab/core-components-carousel",
"version": "0.0.1",
"description": "Carousel component",
"keywords": [],
"license": "MIT",
"sideEffects": [
"**/*.css"
],
"main": "index.js",
"module": "./esm/index.js",
"dependencies": {
"@alfalab/core-components-page-indicator": "^3.0.3",
"@alfalab/core-components-shared": "^2.1.1",
"@alfalab/hooks": "^1.13.1",
"classnames": "^2.5.1",
"swiper": "^12.1.2",
"tslib": "^2.4.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.1 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.9.0 || ^17.0.1 || ^18.0.0 || ^19.0.0"
},
"publishConfig": {
"access": "public",
"directory": "dist"
},
"themesVersion": "15.0.2",
"varsVersion": "11.0.1"
}
179 changes: 179 additions & 0 deletions packages/carousel/src/component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import React, {
type ComponentType,
type CSSProperties,
type FC,
Fragment,
useLayoutEffect,
useRef,
useState,
} from 'react';
import cn from 'classnames';
import { Mousewheel } from 'swiper/modules';
import { Swiper, type SwiperClass, SwiperSlide, type SwiperSlideProps } from 'swiper/react';

import { isObject, NoopComponent } from '@alfalab/core-components-shared';
import { ArrowLeftMIcon } from '@alfalab/icons-glyph/ArrowLeftMIcon';
import { ArrowRightMIcon } from '@alfalab/icons-glyph/ArrowRightMIcon';

import { NavigationButton } from './navigation-button';

import styles from './index.module.css';

type ItemProps = Pick<SwiperSlideProps, 'children'>;

interface CarouselOwnProps extends Pick<CSSProperties, 'height'> {
activeIndex?: number;
defaultActiveIndex?: number;
onChange?: (activeIndex: number) => void;
gap?: number;
visibleItems?: 'auto' | number;
items: ItemProps[];
navigation?: boolean | 'hover';
colors?: 'default' | 'inverted';
}

interface BasePageIndicatorProps extends Pick<CarouselOwnProps, 'colors'> {
elements?: number;
activeElement: number;
className?: string;
}

export interface CarouselProps<
PageIndicatorProps extends BasePageIndicatorProps = BasePageIndicatorProps,
> extends CarouselOwnProps {
PageIndicator?: ComponentType<PageIndicatorProps>;
pageIndicatorProps?: PageIndicatorProps;
}

type SwiperEventHandler = (swiper: SwiperClass) => void;

export function Carousel<PageIndicatorProps extends BasePageIndicatorProps>({
activeIndex,
defaultActiveIndex = 0,
onChange,
visibleItems = 1,
gap,
height,
items,
navigation = false,
colors = 'default',
PageIndicator = NoopComponent,
pageIndicatorProps = {} as PageIndicatorProps,
}: Parameters<FC<CarouselProps<PageIndicatorProps>>>[0]): ReturnType<
FC<CarouselProps<PageIndicatorProps>>
> {
const hasNavigation = navigation !== false;
const swiperRef = useRef<SwiperClass | null>(null);
const [paginationState, setPaginationState] = useState(() => ({ current: 0, total: 0 }));

useLayoutEffect(() => {
const swiper = swiperRef.current;

if (typeof activeIndex === 'number') {
swiper?.slideTo(activeIndex);
}
}, [activeIndex]);

const handleActiveIndexChange: SwiperEventHandler = (swiper) => {
const nextActiveIndex = swiper.activeIndex;

onChange?.(nextActiveIndex);
};

const handlePaginationChange: SwiperEventHandler = (swiper) => {
const { loop, slidesPerGroup } = swiper.params;
const slidesCount =
swiper.virtual && isObject(swiper.params.virtual) && swiper.params.virtual?.enabled
? swiper.virtual.slides.length
: swiper.slides.length;

let total = loop ? Math.ceil(slidesCount / slidesPerGroup!) : swiper.snapGrid.length;

const { freeMode } = swiper.params;

if ((isObject(freeMode) ? freeMode.enabled : freeMode) && total > slidesCount) {
total = slidesCount;
}

let current: number;

if (loop) {
current =
slidesPerGroup! > 1
? Math.floor(swiper.realIndex / slidesPerGroup!)
: swiper.realIndex;
} else if (typeof swiper.snapIndex === 'number') {
current = swiper.snapIndex;
} else {
current = swiper.activeIndex;
}

setPaginationState({ total, current });
};

const handleUpdate: SwiperEventHandler = (swiper) => {
[handlePaginationChange, handleActiveIndexChange].forEach((callback) => callback(swiper));
};

return (
<div className={cn(styles.component, { [styles.navigation]: navigation === 'hover' })}>
<div className={styles.wrapper}>
<Swiper
className={styles.swiper}
modules={[Mousewheel]}
mousewheel={true}
initialSlide={defaultActiveIndex}
spaceBetween={gap}
slidesPerView={visibleItems}
onSlideChange={handleUpdate}
onActiveIndexChange={handleUpdate}
onInit={(swiper) => {
swiperRef.current = swiper;
handleUpdate(swiper);
}}
onUpdate={handleUpdate}
onDestroy={() => {
swiperRef.current = null;
}}
style={{ height }}
>
{items.map((item, i) => (
<SwiperSlide
// eslint-disable-next-line react/no-array-index-key
key={i}
>
{item.children}
</SwiperSlide>
))}
</Swiper>
{hasNavigation && (
<Fragment>
<NavigationButton
colors={colors}
className={cn(styles.navButton, styles.prev)}
icon={ArrowLeftMIcon}
onClick={() => {
swiperRef.current?.slidePrev();
}}
/>
<NavigationButton
colors={colors}
className={cn(styles.navButton, styles.next)}
icon={ArrowRightMIcon}
onClick={() => {
swiperRef.current?.slideNext();
}}
/>
</Fragment>
)}
</div>
<PageIndicator
{...pageIndicatorProps}
colors={colors}
className={cn(styles.pagination, pageIndicatorProps.className)}
activeElement={paginationState.current}
elements={paginationState.total}
/>
</div>
);
}
17 changes: 17 additions & 0 deletions packages/carousel/src/docs/Component.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Meta, Markdown } from '@storybook/addon-docs';
import { ComponentHeader, Tabs } from 'storybook/blocks';
import * as Stories from './Component.stories';

import Description from './description.mdx';
import Development from './development.mdx';
import Changelog from '../../CHANGELOG.md?raw';

<Meta of={Stories} />

<ComponentHeader name='Carousel' children='TODO' />

<Tabs
description={<Description />}
changelog={<Markdown>{Changelog}</Markdown>}
development={<Development />}
/>
21 changes: 21 additions & 0 deletions packages/carousel/src/docs/Component.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';

import { Carousel } from '@alfalab/core-components-carousel';

const meta: Meta<typeof Carousel> = {
title: 'Components/Carousel',
component: Carousel,
id: 'Carousel',
};

type Story = StoryObj<typeof Carousel>;

export const carousel: Story = {
name: 'Carousel',
render: () => {
return <Carousel items={[]} />;
},
};

export default meta;
31 changes: 31 additions & 0 deletions packages/carousel/src/docs/description.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
## Анатомия

TODO

```jsx live
render(() => {
const [activeIndex, setActiveIndex] = React.useState(0);
const [slides, setSlides] = React.useState(() =>
Array.from({ length: 8 }, (_, i) => ({ children: <div>{`Slide ${i}`}</div> })),
);

return (
<Carousel
height={200}
gap={5}
visibleItems={2.4}
activeIndex={activeIndex}
onChange={setActiveIndex}
items={slides}
pagination={true}
navigation={true}
PageIndicator={PageIndicatorRunner}
pageIndicatorProps={{
onActiveElementChange: setActiveIndex,
cycle: true,
duration: 2000,
}}
/>
);
});
```
61 changes: 61 additions & 0 deletions packages/carousel/src/docs/development.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { ArgsTabs, CssVars } from 'storybook/blocks';
import { Plate } from '@alfalab/core-components-plate';
import { Carousel } from '../index';
import styles from '!!raw-loader!../index.module.css';

<style>
{`
.transformer-info {
display: none;
}

html[theme='corp'] .transformer-info {
display: block;
}
`}
</style>

<Plate view='attention' limitContentWidth={false} className='transformer-info'>
В приложениях Альфа-Бизнес (НИБ) компонент должен переключаться в мобильный вид при ширине
768px. Для этого необходимо установить свойство `breakpoint={768}`.

Воспользуйтесь трансформером `button-breakpoint-768`, чтобы добавить брейкпоинт всем кнопкам в
проекте. Трансформер доступен в `@alfalab/core-components-codemod`, начиная с версии `2.5.0`.

Если возникнут вопросы — свяжитесь с командой дизайн-системы Альфа-Бизнес в рокетчате
`#arui-private`.

</Plate>

## Подключение

```jsx
import { Button } from '@alfalab/core-components/button';
import { ButtonDesktop } from '@alfalab/core-components/button/desktop';
import { ButtonMobile } from '@alfalab/core-components/button/mobile';
```

Из индекса импортируется responsive версия компонента.

## Использование dataTestId

В компоненте используется модификатор для `dataTestId`.
Для удобного поиска элементов можно воспользоваться функцией `getButtonTestIds`.
Импорт из `@alfalab/core-components/button/shared`.

Функция возвращает объект:

```jsx
{
button: dataTestId,
spinner: `${dataTestId}-loader`,
};
```

## Свойства

<ArgsTabs components={{ Carousel }} />

## Переменные

<CssVars css={styles} />
Loading
Loading