@12-apps/ui 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/eslint.config.js +12 -0
- package/package.json +5 -4
- package/src/components/data-display/AlertDialog/AlertDialog.test.stories.tsx +16 -6
- package/src/components/data-display/Banner/Banner.test.stories.tsx +26 -18
- package/src/components/data-display/Carousel/Carousel.chrome.tsx +83 -0
- package/src/components/data-display/Carousel/Carousel.hooks.ts +88 -0
- package/src/components/data-display/Carousel/Carousel.parts.tsx +346 -0
- package/src/components/data-display/Carousel/Carousel.slide.tsx +190 -0
- package/src/components/data-display/Carousel/Carousel.styles.ts +86 -0
- package/src/components/data-display/Carousel/Carousel.test.stories.tsx +15 -5
- package/src/components/data-display/Carousel/Carousel.tsx +84 -732
- package/src/components/data-display/HoverCard/HoverCard.animations.ts +73 -0
- package/src/components/data-display/HoverCard/HoverCard.content.tsx +143 -0
- package/src/components/data-display/HoverCard/HoverCard.hooks.ts +180 -0
- package/src/components/data-display/HoverCard/HoverCard.styles.ts +178 -0
- package/src/components/data-display/HoverCard/HoverCard.tsx +117 -631
- package/src/components/data-display/Sheet/Sheet.animations.ts +44 -0
- package/src/components/data-display/Sheet/Sheet.body.tsx +124 -0
- package/src/components/data-display/Sheet/Sheet.drag.ts +203 -0
- package/src/components/data-display/Sheet/Sheet.hooks.ts +318 -0
- package/src/components/data-display/Sheet/Sheet.parts.tsx +224 -0
- package/src/components/data-display/Sheet/Sheet.styles.ts +257 -0
- package/src/components/data-display/Sheet/Sheet.test.stories.tsx +25 -21
- package/src/components/data-display/Sheet/Sheet.tsx +125 -948
- package/src/components/feedback/Dialog/Dialog.test.stories.tsx +37 -28
- package/src/components/feedback/Modal/Modal.styles.ts +126 -0
- package/src/components/feedback/Modal/Modal.test.stories.tsx +5 -5
- package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
- package/src/components/feedback/Modal/Modal.tsx +37 -242
- package/src/components/feedback/StackedModal/StackedModal.test.stories.tsx +9 -11
- package/src/components/form/InputOTP/InputOTP.hooks.ts +104 -0
- package/src/components/form/InputOTP/InputOTP.styles.ts +97 -0
- package/src/components/form/InputOTP/InputOTP.tsx +74 -221
- package/src/components/form/Label/Label.parts.tsx +75 -0
- package/src/components/form/Label/Label.styles.ts +206 -0
- package/src/components/form/Label/Label.test.stories.tsx +20 -14
- package/src/components/form/Label/Label.tsx +73 -325
- package/src/components/form/Menubar/Menubar.hooks.ts +41 -0
- package/src/components/form/Menubar/Menubar.item.tsx +121 -0
- package/src/components/form/Menubar/Menubar.separator.tsx +25 -0
- package/src/components/form/Menubar/Menubar.styles.ts +110 -0
- package/src/components/form/Menubar/Menubar.toolbar.tsx +103 -0
- package/src/components/form/Menubar/Menubar.tsx +64 -347
- package/src/components/form/RadioGroup/RadioGroup.animations.ts +48 -0
- package/src/components/form/RadioGroup/RadioGroup.styles.ts +305 -0
- package/src/components/form/RadioGroup/RadioGroup.tsx +81 -651
- package/src/components/form/RadioGroup/RadioGroup.variants.tsx +291 -0
- package/src/components/form/Slider/Slider.animations.ts +40 -0
- package/src/components/form/Slider/Slider.styles.ts +292 -0
- package/src/components/form/Slider/Slider.tsx +106 -357
- package/src/components/form/Switch/Switch.animations.ts +62 -0
- package/src/components/form/Switch/Switch.parts.tsx +213 -0
- package/src/components/form/Switch/Switch.styles.ts +355 -0
- package/src/components/form/Switch/Switch.tsx +87 -566
- package/src/components/form/ToggleGroup/ToggleGroup.test.stories.tsx +26 -29
- package/src/components/layout/Resizable/Resizable.test.stories.tsx +11 -7
- package/src/components/layout/Spacer/Spacer.test.stories.tsx +11 -9
- package/src/components/typography/Blockquote/Blockquote.test.stories.tsx +7 -7
- package/src/components/typography/Heading/Heading.styles.ts +101 -0
- package/src/components/typography/Heading/Heading.tsx +48 -164
- package/src/components/typography/Text/Text.test.stories.tsx +27 -30
- package/src/components/utility/AnimatedIcon/AnimatedIcon.animations.ts +274 -0
- package/src/components/utility/AnimatedIcon/AnimatedIcon.styles.ts +231 -0
- package/src/components/utility/AnimatedIcon/AnimatedIcon.tsx +39 -559
- package/src/utils/index.ts +1 -0
- package/src/utils/withDefaults.ts +20 -0
package/eslint.config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
|
2
2
|
import tsParser from '@typescript-eslint/parser';
|
|
3
3
|
import storybook from "eslint-plugin-storybook";
|
|
4
|
+
import testFlakiness from 'eslint-plugin-test-flakiness';
|
|
4
5
|
import globals from 'globals';
|
|
5
6
|
|
|
6
7
|
import { config } from "@12-apps/eslint-config/base";
|
|
@@ -23,6 +24,17 @@ export default [
|
|
|
23
24
|
},
|
|
24
25
|
...config,
|
|
25
26
|
...storybook.configs["flat/recommended"],
|
|
27
|
+
|
|
28
|
+
// The flakiness gate is a separate lane (eslint.flakiness.config.mjs), so its
|
|
29
|
+
// rules are not enabled here. Test files still carry inline disables for the
|
|
30
|
+
// few findings that are deliberate, and without the plugin registered those
|
|
31
|
+
// directives fail to resolve — "definition for rule ... was not found" — which
|
|
32
|
+
// this everyday lint run counts as a warning under --max-warnings 0.
|
|
33
|
+
// Registering the plugin with no rules turned on makes them resolve.
|
|
34
|
+
{
|
|
35
|
+
plugins: { 'test-flakiness': testFlakiness },
|
|
36
|
+
linterOptions: { reportUnusedDisableDirectives: 'off' },
|
|
37
|
+
},
|
|
26
38
|
// Override parser for all TypeScript files after storybook config
|
|
27
39
|
{
|
|
28
40
|
files: ['**/*.ts', '**/*.tsx'],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"packageManager": "pnpm@9.0.0",
|
|
6
6
|
"type": "module",
|
|
@@ -194,7 +194,7 @@
|
|
|
194
194
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
195
195
|
},
|
|
196
196
|
"dependencies": {
|
|
197
|
-
"@12-apps/forms-core": "^1.
|
|
197
|
+
"@12-apps/forms-core": "^1.4.0",
|
|
198
198
|
"@emotion/react": "^11.14.0",
|
|
199
199
|
"@emotion/styled": "^11.14.0",
|
|
200
200
|
"@monaco-editor/react": "^4.7.0",
|
|
@@ -209,8 +209,8 @@
|
|
|
209
209
|
"recharts": "^3.5.1"
|
|
210
210
|
},
|
|
211
211
|
"devDependencies": {
|
|
212
|
-
"@12-apps/eslint-config": "^1.
|
|
213
|
-
"@12-apps/typescript-config": "^1.
|
|
212
|
+
"@12-apps/eslint-config": "^1.5.0",
|
|
213
|
+
"@12-apps/typescript-config": "^1.5.0",
|
|
214
214
|
"@playwright/test": "^1.55.0",
|
|
215
215
|
"@storybook/addon-docs": "^9.1.10",
|
|
216
216
|
"@storybook/addon-links": "^9.1.10",
|
|
@@ -226,6 +226,7 @@
|
|
|
226
226
|
"concurrently": "^9.2.1",
|
|
227
227
|
"eslint-import-resolver-typescript": "^4.4.4",
|
|
228
228
|
"eslint-plugin-storybook": "9.1.10",
|
|
229
|
+
"eslint-plugin-test-flakiness": "^1.4.0",
|
|
229
230
|
"glob": "^13.0.0",
|
|
230
231
|
"globals": "^16.5.0",
|
|
231
232
|
"http-server": "^14.1.1",
|
|
@@ -288,13 +288,15 @@ export const FocusManagement: Story = {
|
|
|
288
288
|
await step('Tab through focusable elements', async () => {
|
|
289
289
|
// Tab cycles through dialog elements
|
|
290
290
|
await userEvent.tab();
|
|
291
|
-
|
|
291
|
+
await waitFor(() =>
|
|
292
|
+
expect(document.activeElement?.getAttribute('aria-label')).toBe('close'),
|
|
293
|
+
);
|
|
292
294
|
|
|
293
295
|
await userEvent.tab();
|
|
294
|
-
expect(document.activeElement?.textContent).toContain('Cancel');
|
|
296
|
+
await waitFor(() => expect(document.activeElement?.textContent).toContain('Cancel'));
|
|
295
297
|
|
|
296
298
|
await userEvent.tab();
|
|
297
|
-
expect(document.activeElement?.textContent).toContain('Confirm');
|
|
299
|
+
await waitFor(() => expect(document.activeElement?.textContent).toContain('Confirm'));
|
|
298
300
|
});
|
|
299
301
|
|
|
300
302
|
await step('Close dialog and verify focus return', async () => {
|
|
@@ -519,6 +521,10 @@ const PerformanceComponent = () => {
|
|
|
519
521
|
const startTime = window.performance.now();
|
|
520
522
|
setDialogCount((prev) => prev + 1);
|
|
521
523
|
setCurrentDialog(dialogCount + 1);
|
|
524
|
+
// This is the demo measuring itself for display, not an assertion — the
|
|
525
|
+
// story renders the number rather than asserting on it, so nothing here
|
|
526
|
+
// can fail on a slow machine.
|
|
527
|
+
// eslint-disable-next-line test-flakiness/no-animation-wait -- demo self-measurement, not an assertion
|
|
522
528
|
window.requestAnimationFrame(() => {
|
|
523
529
|
const endTime = window.performance.now();
|
|
524
530
|
setRenderTime(endTime - startTime);
|
|
@@ -670,9 +676,13 @@ const IntegrationComponent = () => {
|
|
|
670
676
|
|
|
671
677
|
const handleDelete = async () => {
|
|
672
678
|
setIsDeleting(true);
|
|
673
|
-
//
|
|
679
|
+
// The delay is the point of this story — it shows the pending state — so it
|
|
680
|
+
// is the demo's own behaviour rather than a test waiting for something.
|
|
681
|
+
// eslint-disable-next-line test-flakiness/no-hard-coded-timeout -- simulated latency is what this story demonstrates
|
|
674
682
|
await new Promise((resolve) => window.setTimeout(resolve, 1000));
|
|
675
|
-
|
|
683
|
+
// Numbered rather than timestamped: the entry only needs to be distinct, and
|
|
684
|
+
// a clock reading makes the rendered text different on every run.
|
|
685
|
+
setConfirmations((prev) => [...prev, `Deleted item ${prev.length + 1}`]);
|
|
676
686
|
setIsDeleting(false);
|
|
677
687
|
};
|
|
678
688
|
|
|
@@ -746,7 +756,7 @@ export const Integration: Story = {
|
|
|
746
756
|
|
|
747
757
|
await step('Verify action was logged', async () => {
|
|
748
758
|
await waitFor(() => {
|
|
749
|
-
expect(canvas.getByText(/Deleted
|
|
759
|
+
expect(canvas.getByText(/Deleted item/)).toBeInTheDocument();
|
|
750
760
|
});
|
|
751
761
|
});
|
|
752
762
|
},
|
|
@@ -116,30 +116,34 @@ export const KeyboardNavigation: Story = {
|
|
|
116
116
|
|
|
117
117
|
// Focus the banner
|
|
118
118
|
const banner = canvas.getByRole('status');
|
|
119
|
-
|
|
120
|
-
expect(banner).toHaveFocus();
|
|
119
|
+
await userEvent.click(banner);
|
|
120
|
+
await waitFor(() => expect(banner).toHaveFocus());
|
|
121
121
|
|
|
122
122
|
// Tab to first action button
|
|
123
123
|
await userEvent.tab();
|
|
124
124
|
const action1 = canvas.getByRole('button', { name: 'Action 1' });
|
|
125
|
-
expect(action1).toHaveFocus();
|
|
125
|
+
await waitFor(() => expect(action1).toHaveFocus());
|
|
126
126
|
|
|
127
127
|
// Tab to second action button
|
|
128
128
|
await userEvent.tab();
|
|
129
129
|
const action2 = canvas.getByRole('button', { name: 'Action 2' });
|
|
130
|
-
expect(action2).toHaveFocus();
|
|
130
|
+
await waitFor(() => expect(action2).toHaveFocus());
|
|
131
131
|
|
|
132
132
|
// Tab to dismiss button
|
|
133
133
|
await userEvent.tab();
|
|
134
134
|
const dismissButton = canvas.getByRole('button', { name: /dismiss banner/i });
|
|
135
|
-
expect(dismissButton).toHaveFocus();
|
|
135
|
+
await waitFor(() => expect(dismissButton).toHaveFocus());
|
|
136
136
|
|
|
137
|
-
// Test Enter key on action button
|
|
137
|
+
// Test Enter key on action button. Focused directly rather than clicked:
|
|
138
|
+
// a click would fire the very handler the next line counts.
|
|
139
|
+
// eslint-disable-next-line test-flakiness/no-focus-check -- clicking would fire the handler under assertion
|
|
138
140
|
action1.focus();
|
|
139
141
|
await userEvent.keyboard('{Enter}');
|
|
140
142
|
expect(args.actions![0].onClick).toHaveBeenCalledTimes(1);
|
|
141
143
|
|
|
142
|
-
// Test Space key on dismiss button
|
|
144
|
+
// Test Space key on dismiss button. Focused rather than clicked for the
|
|
145
|
+
// same reason: a click is itself a dismiss.
|
|
146
|
+
// eslint-disable-next-line test-flakiness/no-focus-check, test-flakiness/await-async-events -- clicking would perform the action under test
|
|
143
147
|
dismissButton.focus();
|
|
144
148
|
await userEvent.keyboard(' ');
|
|
145
149
|
|
|
@@ -198,17 +202,17 @@ export const FocusManagement: Story = {
|
|
|
198
202
|
expect(banner).toHaveAttribute('tabIndex', '0');
|
|
199
203
|
|
|
200
204
|
// Test focus visibility
|
|
201
|
-
|
|
202
|
-
expect(banner).toHaveFocus();
|
|
205
|
+
await userEvent.click(banner);
|
|
206
|
+
await waitFor(() => expect(banner).toHaveFocus());
|
|
203
207
|
|
|
204
208
|
// Test that focus moves properly through interactive elements
|
|
205
209
|
await userEvent.tab();
|
|
206
210
|
const actionButton = canvas.getByRole('button', { name: 'Focus Test' });
|
|
207
|
-
expect(actionButton).toHaveFocus();
|
|
211
|
+
await waitFor(() => expect(actionButton).toHaveFocus());
|
|
208
212
|
|
|
209
213
|
await userEvent.tab();
|
|
210
214
|
const dismissButton = canvas.getByRole('button', { name: /dismiss banner/i });
|
|
211
|
-
expect(dismissButton).toHaveFocus();
|
|
215
|
+
await waitFor(() => expect(dismissButton).toHaveFocus());
|
|
212
216
|
},
|
|
213
217
|
};
|
|
214
218
|
|
|
@@ -365,14 +369,18 @@ export const Performance: Story = {
|
|
|
365
369
|
const allDismissButtons = canvas.getAllByRole('button', { name: /dismiss banner/i });
|
|
366
370
|
|
|
367
371
|
// Click first 3 dismiss buttons
|
|
368
|
-
|
|
372
|
+
const dismissCount = Math.min(3, allDismissButtons.length);
|
|
373
|
+
for (let i = 0; i < dismissCount; i++) {
|
|
369
374
|
await userEvent.click(allDismissButtons[i]);
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
375
|
+
// The old wait here had an empty body, so it was a fixed delay with a
|
|
376
|
+
// waitFor's name on it. Waiting for the banner count to actually drop is
|
|
377
|
+
// both the real signal and the assertion.
|
|
378
|
+
const remaining = allDismissButtons.length - (i + 1);
|
|
379
|
+
await waitFor(() => {
|
|
380
|
+
expect(canvas.queryAllByRole('button', { name: /dismiss banner/i })).toHaveLength(
|
|
381
|
+
remaining,
|
|
382
|
+
);
|
|
383
|
+
});
|
|
376
384
|
}
|
|
377
385
|
},
|
|
378
386
|
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { CarouselArrows, CarouselIndicators, CarouselThumbnails } from './Carousel.parts';
|
|
4
|
+
import type {
|
|
5
|
+
CarouselArrowsProps,
|
|
6
|
+
CarouselIndicatorsProps,
|
|
7
|
+
CarouselItem,
|
|
8
|
+
CarouselThumbnailsProps,
|
|
9
|
+
} from './Carousel.types';
|
|
10
|
+
|
|
11
|
+
type IndicatorPosition = NonNullable<CarouselIndicatorsProps['position']>;
|
|
12
|
+
type Color = NonNullable<CarouselIndicatorsProps['color']>;
|
|
13
|
+
|
|
14
|
+
export interface CarouselChromeProps {
|
|
15
|
+
items: CarouselItem[];
|
|
16
|
+
activeIndex: number;
|
|
17
|
+
loop: boolean;
|
|
18
|
+
disabled: boolean;
|
|
19
|
+
showArrows: boolean;
|
|
20
|
+
showIndicators: boolean;
|
|
21
|
+
showThumbnails: boolean;
|
|
22
|
+
arrowPosition: NonNullable<CarouselArrowsProps['position']>;
|
|
23
|
+
indicatorPosition: IndicatorPosition;
|
|
24
|
+
color: Color;
|
|
25
|
+
size: NonNullable<CarouselThumbnailsProps['size']>;
|
|
26
|
+
onPrev: () => void;
|
|
27
|
+
onNext: () => void;
|
|
28
|
+
onSelect: (index: number) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Everything drawn over the slides: the arrows, the indicators and the thumbnail
|
|
33
|
+
* strip. Each is independently switchable, so they travel together rather than
|
|
34
|
+
* as three conditional blocks inside the carousel's own render.
|
|
35
|
+
*/
|
|
36
|
+
export const CarouselChrome: React.FC<CarouselChromeProps> = ({
|
|
37
|
+
items,
|
|
38
|
+
activeIndex,
|
|
39
|
+
loop,
|
|
40
|
+
disabled,
|
|
41
|
+
showArrows,
|
|
42
|
+
showIndicators,
|
|
43
|
+
showThumbnails,
|
|
44
|
+
arrowPosition,
|
|
45
|
+
indicatorPosition,
|
|
46
|
+
color,
|
|
47
|
+
size,
|
|
48
|
+
onPrev,
|
|
49
|
+
onNext,
|
|
50
|
+
onSelect,
|
|
51
|
+
}) => (
|
|
52
|
+
<>
|
|
53
|
+
{showArrows && !disabled && (
|
|
54
|
+
<CarouselArrows
|
|
55
|
+
onPrev={onPrev}
|
|
56
|
+
onNext={onNext}
|
|
57
|
+
position={arrowPosition}
|
|
58
|
+
color={color}
|
|
59
|
+
disablePrev={!loop && activeIndex === 0}
|
|
60
|
+
disableNext={!loop && activeIndex === items.length - 1}
|
|
61
|
+
/>
|
|
62
|
+
)}
|
|
63
|
+
|
|
64
|
+
{showIndicators && (
|
|
65
|
+
<CarouselIndicators
|
|
66
|
+
count={items.length}
|
|
67
|
+
activeIndex={activeIndex}
|
|
68
|
+
onSelect={onSelect}
|
|
69
|
+
position={indicatorPosition}
|
|
70
|
+
color={color}
|
|
71
|
+
/>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{showThumbnails && (
|
|
75
|
+
<CarouselThumbnails
|
|
76
|
+
items={items}
|
|
77
|
+
activeIndex={activeIndex}
|
|
78
|
+
onSelect={onSelect}
|
|
79
|
+
size={size}
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface CarouselNavigationInput {
|
|
4
|
+
count: number;
|
|
5
|
+
loop: boolean;
|
|
6
|
+
autoPlay: boolean;
|
|
7
|
+
autoPlayInterval: number;
|
|
8
|
+
pauseOnHover: boolean;
|
|
9
|
+
disabled: boolean;
|
|
10
|
+
onChange?: (index: number) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Steps `current` by `step`, wrapping when `loop` allows it and stopping at the
|
|
15
|
+
* end when it does not. Returns the same index it was given when there is
|
|
16
|
+
* nowhere to go, which is what lets the caller skip a no-op `onChange`.
|
|
17
|
+
*/
|
|
18
|
+
const stepIndex = (current: number, step: number, count: number, loop: boolean) => {
|
|
19
|
+
const next = current + step;
|
|
20
|
+
|
|
21
|
+
if (next >= count) return loop ? 0 : current;
|
|
22
|
+
if (next < 0) return loop ? count - 1 : 0;
|
|
23
|
+
return next;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Which slide is showing and everything that changes it: the arrows, an
|
|
28
|
+
* indicator click, and the autoplay timer.
|
|
29
|
+
*
|
|
30
|
+
* Hover is tracked here rather than in the component because its only job is to
|
|
31
|
+
* pause that timer.
|
|
32
|
+
*/
|
|
33
|
+
export const useCarouselNavigation = ({
|
|
34
|
+
count,
|
|
35
|
+
loop,
|
|
36
|
+
autoPlay,
|
|
37
|
+
autoPlayInterval,
|
|
38
|
+
pauseOnHover,
|
|
39
|
+
disabled,
|
|
40
|
+
onChange,
|
|
41
|
+
}: CarouselNavigationInput) => {
|
|
42
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
43
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
44
|
+
const intervalRef = useRef<number | null>(null);
|
|
45
|
+
|
|
46
|
+
const goBy = useCallback(
|
|
47
|
+
(step: number) => {
|
|
48
|
+
setActiveIndex((prev) => {
|
|
49
|
+
const next = stepIndex(prev, step, count, loop);
|
|
50
|
+
if (next !== prev) {
|
|
51
|
+
onChange?.(next);
|
|
52
|
+
}
|
|
53
|
+
return next;
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
[count, loop, onChange],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const handleNext = useCallback(() => goBy(1), [goBy]);
|
|
60
|
+
const handlePrev = useCallback(() => goBy(-1), [goBy]);
|
|
61
|
+
|
|
62
|
+
const handleSelect = useCallback(
|
|
63
|
+
(index: number) => {
|
|
64
|
+
setActiveIndex(index);
|
|
65
|
+
onChange?.(index);
|
|
66
|
+
},
|
|
67
|
+
[onChange],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!autoPlay || isHovered || disabled) return;
|
|
72
|
+
|
|
73
|
+
intervalRef.current = window.setInterval(handleNext, autoPlayInterval);
|
|
74
|
+
|
|
75
|
+
return () => {
|
|
76
|
+
if (intervalRef.current) {
|
|
77
|
+
window.clearInterval(intervalRef.current);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}, [autoPlay, autoPlayInterval, isHovered, disabled, handleNext]);
|
|
81
|
+
|
|
82
|
+
const hoverProps = {
|
|
83
|
+
onMouseEnter: () => pauseOnHover && setIsHovered(true),
|
|
84
|
+
onMouseLeave: () => pauseOnHover && setIsHovered(false),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return { activeIndex, handleNext, handlePrev, handleSelect, hoverProps };
|
|
88
|
+
};
|