feat(deps): Tier 2 UX polish — embla, lightbox, gestures, virtuoso, motion

Installs all five Tier 2 polish deps the audit flagged. Each integrates
where it adds concrete value today:

- **embla-carousel-react** — shadcn-style `<Carousel>` primitive in
  `src/components/ui/carousel.tsx`. Available for future berth/yacht
  photo galleries; no current call site beyond the primitive.
- **yet-another-react-lightbox** — wired into the image branch of
  `file-preview-dialog.tsx`. Clicking the preview image now opens a
  fullscreen lightbox with zoom/pan/keyboard nav. Lazy-loaded so the
  ~50kb only ships when a user actually previews an image.
- **@use-gesture/react** — `usePinch` on the PdfViewer's content
  pane for native pinch-zoom on tablets/phones. Clamped to the
  same [50%, 300%] range as the +/- buttons; desktop wheel still
  scrolls.
- **react-virtuoso** — installed but NOT wired. Inbox is naturally
  bounded by recent-notifications filter at ~10-20 items; ScrollArea
  handles it fine. Reserve for actual scale issues (admin audit log
  archive, etc.).
- **motion** — installed but NOT wired. Pipeline kanban uses
  dnd-kit's own transforms and conflicts with motion's layout
  animation. @formkit/auto-animate already handles list-mutation
  animations elsewhere. Available for opportunistic adoption when
  a polish surface emerges that the existing libraries don't cover.

Verified: tsc clean, vitest 1315/1315, next build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 23:29:22 +02:00
parent 4329db7fc3
commit d1c9469fa7
5 changed files with 418 additions and 5 deletions

View File

@@ -64,6 +64,7 @@
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@types/pdfkit": "^0.17.6",
"@use-gesture/react": "^10.3.1",
"archiver": "^7.0.1",
"better-auth": "^1.6.11",
"browser-image-compression": "^2.0.2",
@@ -73,6 +74,7 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.2",
"embla-carousel-react": "^8.6.0",
"imapflow": "^1.3.3",
"ioredis": "^5.10.1",
"iso-3166-2": "^1.0.0",
@@ -82,6 +84,7 @@
"lucide-react": "^1.14.0",
"mailparser": "^3.9.8",
"minio": "^8.0.7",
"motion": "^12.38.0",
"next": "16.2.6",
"next-intl": "^4.11.2",
"next-themes": "^0.4.6",
@@ -105,6 +108,7 @@
"react-number-format": "^5.4.5",
"react-pdf": "^10.4.1",
"react-resizable-panels": "^3.0.6",
"react-virtuoso": "^4.18.7",
"recharts": "^3.8.1",
"sharp": "^0.34.5",
"socket.io": "^4.8.3",
@@ -118,6 +122,7 @@
"unpdf": "^1.6.2",
"vaul": "^1.1.2",
"web-vitals": "^5.2.0",
"yet-another-react-lightbox": "^3.32.0",
"zod": "^4.4.3",
"zustand": "^5.0.13"
},

147
pnpm-lock.yaml generated
View File

@@ -118,6 +118,9 @@ importers:
'@types/pdfkit':
specifier: ^0.17.6
version: 0.17.6
'@use-gesture/react':
specifier: ^10.3.1
version: 10.3.1(react@19.2.6)
archiver:
specifier: ^7.0.1
version: 7.0.1
@@ -145,6 +148,9 @@ importers:
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9)
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0(react@19.2.6)
imapflow:
specifier: ^1.3.3
version: 1.3.3
@@ -172,6 +178,9 @@ importers:
minio:
specifier: ^8.0.7
version: 8.0.7
motion:
specifier: ^12.38.0
version: 12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
next:
specifier: 16.2.6
version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -241,6 +250,9 @@ importers:
react-resizable-panels:
specifier: ^3.0.6
version: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-virtuoso:
specifier: ^4.18.7
version: 4.18.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1)
@@ -280,6 +292,9 @@ importers:
web-vitals:
specifier: ^5.2.0
version: 5.2.0
yet-another-react-lightbox:
specifier: ^3.32.0
version: 3.32.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -3446,6 +3461,14 @@ packages:
cpu: [x64]
os: [win32]
'@use-gesture/core@10.3.1':
resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==}
'@use-gesture/react@10.3.1':
resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==}
peerDependencies:
react: '>= 16.8.0'
'@vitejs/plugin-react@6.0.1':
resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4434,6 +4457,19 @@ packages:
electron-to-chromium@1.5.353:
resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==}
embla-carousel-react@8.6.0:
resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==}
peerDependencies:
react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
embla-carousel-reactive-utils@8.6.0:
resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==}
peerDependencies:
embla-carousel: 8.6.0
embla-carousel@8.6.0:
resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==}
emoji-regex-xs@1.0.0:
resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
@@ -4786,6 +4822,20 @@ packages:
forwarded-parse@2.1.2:
resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==}
framer-motion@12.38.0:
resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -5631,6 +5681,26 @@ packages:
socks:
optional: true
motion-dom@12.38.0:
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
motion-utils@12.36.0:
resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==}
motion@12.38.0:
resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -6224,6 +6294,12 @@ packages:
'@types/react':
optional: true
react-virtuoso@4.18.7:
resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==}
peerDependencies:
react: '>=16 || >=17 || >= 18 || >= 19'
react-dom: '>=16 || >=17 || >= 18 || >=19'
react@19.2.6:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'}
@@ -7281,6 +7357,20 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yet-another-react-lightbox@3.32.0:
resolution: {integrity: sha512-FWODOMrE07i3O5MeWRcYlcnAUk518zkUYKAe307pVW5pkey3hKMcAIWn8yMIURzGvbd3m9eghWO9CocEZmQPIg==}
engines: {node: '>=14'}
peerDependencies:
'@types/react': ^16 || ^17 || ^18 || ^19
'@types/react-dom': ^16 || ^17 || ^18 || ^19
react: ^16.8.0 || ^17 || ^18 || ^19
react-dom: ^16.8.0 || ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -10188,6 +10278,13 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@use-gesture/core@10.3.1': {}
'@use-gesture/react@10.3.1(react@19.2.6)':
dependencies:
'@use-gesture/core': 10.3.1
react: 19.2.6
'@vitejs/plugin-react@6.0.1(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
@@ -11097,6 +11194,18 @@ snapshots:
electron-to-chromium@1.5.353: {}
embla-carousel-react@8.6.0(react@19.2.6):
dependencies:
embla-carousel: 8.6.0
embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0)
react: 19.2.6
embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0):
dependencies:
embla-carousel: 8.6.0
embla-carousel@8.6.0: {}
emoji-regex-xs@1.0.0: {}
emoji-regex@10.6.0: {}
@@ -11626,6 +11735,16 @@ snapshots:
forwarded-parse@2.1.2: {}
framer-motion@12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
motion-dom: 12.38.0
motion-utils: 12.36.0
tslib: 2.8.1
optionalDependencies:
'@emotion/is-prop-valid': 1.4.0
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
fsevents@2.3.2:
optional: true
@@ -12464,6 +12583,21 @@ snapshots:
socks: 2.8.8
optional: true
motion-dom@12.38.0:
dependencies:
motion-utils: 12.36.0
motion-utils@12.36.0: {}
motion@12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
framer-motion: 12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tslib: 2.8.1
optionalDependencies:
'@emotion/is-prop-valid': 1.4.0
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
mrmime@2.0.1: {}
ms@2.1.3: {}
@@ -13068,6 +13202,11 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
react-virtuoso@4.18.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react@19.2.6: {}
readable-stream@2.3.8:
@@ -14296,6 +14435,14 @@ snapshots:
yaml@2.8.4:
optional: true
yet-another-react-lightbox@3.32.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
yocto-queue@0.1.0: {}
yocto-queue@1.2.2: {}

View File

@@ -2,11 +2,15 @@
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ExternalLink } from 'lucide-react';
import { ExternalLink, ZoomIn } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
// yet-another-react-lightbox is ~50kb, lazy-load it.
const Lightbox = dynamic(() => import('yet-another-react-lightbox'), { ssr: false });
import 'yet-another-react-lightbox/styles.css';
// pdfjs-dist is ~150kb gzip — lazy-load so routes that never preview
// PDFs don't ship it. ssr:false because the worker setup needs window.
const PdfViewer = dynamic(() => import('./pdf-viewer').then((m) => ({ default: m.PdfViewer })), {
@@ -36,6 +40,7 @@ export function FilePreviewDialog({
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
useEffect(() => {
if (!open || !fileId) {
@@ -95,14 +100,20 @@ export function FilePreviewDialog({
)}
{!loading && !error && previewUrl && isImage && (
<div className="flex h-full items-center justify-center p-4">
<button
type="button"
onClick={() => setLightboxOpen(true)}
className="flex h-full w-full items-center justify-center p-4 group"
aria-label="Open in lightbox"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt={fileName ?? 'Preview'}
className="max-h-full max-w-full object-contain rounded"
className="max-h-full max-w-full object-contain rounded transition-transform group-hover:scale-[1.02]"
/>
</div>
<ZoomIn className="absolute right-6 bottom-6 h-6 w-6 rounded-full bg-background/80 p-1 text-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</button>
)}
{!loading && !error && previewUrl && isPdf && (
@@ -110,6 +121,19 @@ export function FilePreviewDialog({
)}
</div>
</DialogContent>
{/* Lightbox renders OUTSIDE the parent Dialog so the dialog's own
* bounds don't clip the fullscreen overlay. yet-another-react-
* lightbox handles zoom/pan/keyboard nav out of the box. */}
{previewUrl && isImage && (
<Lightbox
open={lightboxOpen}
close={() => setLightboxOpen(false)}
slides={[{ src: previewUrl, alt: fileName ?? 'Preview' }]}
controller={{ closeOnBackdropClick: true }}
carousel={{ finite: true }}
/>
)}
</Dialog>
);
}

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import { usePinch } from '@use-gesture/react';
import { ChevronLeft, ChevronRight, Loader2, Minus, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -61,6 +62,22 @@ export function PdfViewer({ url, fileName }: PdfViewerProps) {
setError(null);
}, [url]);
// Pinch-zoom on touch devices. usePinch's `offset` already maps
// gesture distance to a smoothly-changing scalar; we clamp it to
// the same [0.5, 3] range as the +/- buttons.
const pinchBindings = usePinch(
({ offset: [s] }) => {
setScale(Math.max(0.5, Math.min(3, s)));
},
{
scaleBounds: { min: 0.5, max: 3 },
from: () => [scale, 0],
// Don't hijack wheel events — desktop users zoom via buttons,
// wheel still scrolls the page.
eventOptions: { passive: false },
},
);
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2 border-b bg-muted/40 px-3 py-2 text-sm">
@@ -113,7 +130,14 @@ export function PdfViewer({ url, fileName }: PdfViewerProps) {
</div>
</div>
<div className="flex-1 overflow-auto bg-muted/30 p-4">
<div
className="flex-1 overflow-auto bg-muted/30 p-4 touch-pan-y"
// Pinch-zoom on touch devices (tablets, phones). On desktop the
// +/- buttons stay primary; the gesture handler ignores wheel
// events so it doesn't fight scroll-zoom. Range matches the
// button zoom (50%300%).
{...pinchBindings()}
>
{error ? (
<div className="flex h-full items-center justify-center text-sm text-destructive">
{error}

View File

@@ -0,0 +1,213 @@
'use client';
import * as React from 'react';
import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const ctx = React.useContext(CarouselContext);
if (!ctx) {
throw new Error('useCarousel must be used within a <Carousel />');
}
return ctx;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(({ orientation = 'horizontal', opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{ ...opts, axis: orientation === 'horizontal' ? 'x' : 'y' },
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);
const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);
return () => {
api.off('select', onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api,
opts,
orientation,
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={(e) => {
if (e.key === 'ArrowLeft') {
e.preventDefault();
scrollPrev();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
scrollNext();
}
}}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
});
Carousel.displayName = 'Carousel';
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className,
)}
{...props}
/>
</div>
);
},
);
CarouselContent.displayName = 'CarouselContent';
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className,
)}
{...props}
/>
);
},
);
CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-left-12 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
},
);
CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
},
);
CarouselNext.displayName = 'CarouselNext';
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};