50 .tsx components and 6 .ts hook files use React client APIs
(createContext, useState, useEffect, useRef, useMemo, useCallback,
forwardRef, etc.) but lacked the "use client" directive at the top
of the source file. tsc-emitted dist files therefore also lacked it.
Older Next.js / Turbopack versions traversed the import graph and
implicitly treated the imports as client when their consumer was a
client component. Next.js 16's stricter Turbopack rejects this: any
file that touches client-only React APIs must declare "use client"
explicitly, regardless of where it is imported from.
Symptom in consumers (gscSupport build, 2026-05-17):
./templates/limitless-ui/dist/theme/ThemeProvider.js:2:10
You're importing a module that depends on `createContext` into a
React Server Component module. This API is only available in
Client Components. To fix, mark the file (or its parent) with
the `"use client"` directive.
Adding the directive at source-file level cascades through tsc into
the emitted dist/ — verified gscSupport + gscCRM build cleanly after
this change.
Affected files (50 total):
src/theme/ThemeProvider.tsx
src/hooks/useDisclosure.ts
src/components/{Accordion,Carousel,DualListBox,Form,Wizard,…}.tsx
src/validation/hooks/{useValidation,useFieldValidation,…}.ts
src/genui/hooks/{useGenUI,useWebMCP}.ts
... and more — every component that touches createContext / a
React hook now self-declares as client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
216 lines
5.7 KiB
TypeScript
216 lines
5.7 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
|
export interface CarouselItem {
|
|
/** Image source */
|
|
src?: string;
|
|
/** Alt text for image */
|
|
alt?: string;
|
|
/** Caption title */
|
|
title?: string;
|
|
/** Caption description */
|
|
description?: string;
|
|
/** Custom content instead of image */
|
|
content?: React.ReactNode;
|
|
}
|
|
|
|
export interface CarouselProps {
|
|
/** Carousel items */
|
|
items: CarouselItem[];
|
|
/** Show indicators */
|
|
indicators?: boolean;
|
|
/** Show controls (prev/next) */
|
|
controls?: boolean;
|
|
/** Auto play */
|
|
autoPlay?: boolean;
|
|
/** Interval in ms */
|
|
interval?: number;
|
|
/** Pause on hover */
|
|
pauseOnHover?: boolean;
|
|
/** Enable keyboard navigation */
|
|
keyboard?: boolean;
|
|
/** Enable touch/swipe */
|
|
touch?: boolean;
|
|
/** Crossfade animation */
|
|
fade?: boolean;
|
|
/** Dark variant */
|
|
dark?: boolean;
|
|
/** Active index (controlled) */
|
|
activeIndex?: number;
|
|
/** Callback when slide changes */
|
|
onSlide?: (index: number) => void;
|
|
/** Additional CSS classes */
|
|
className?: string;
|
|
}
|
|
|
|
export const Carousel: React.FC<CarouselProps> = ({
|
|
items,
|
|
indicators = true,
|
|
controls = true,
|
|
autoPlay = false,
|
|
interval = 5000,
|
|
pauseOnHover = true,
|
|
keyboard = true,
|
|
touch = true,
|
|
fade = false,
|
|
dark = false,
|
|
activeIndex: controlledIndex,
|
|
onSlide,
|
|
className = '',
|
|
}) => {
|
|
const [currentIndex, setCurrentIndex] = useState(controlledIndex ?? 0);
|
|
const [isPaused, setIsPaused] = useState(false);
|
|
const [touchStart, setTouchStart] = useState<number | null>(null);
|
|
const carouselRef = useRef<HTMLDivElement>(null);
|
|
|
|
const activeIndex = controlledIndex ?? currentIndex;
|
|
|
|
const goToSlide = useCallback((index: number) => {
|
|
const newIndex = index < 0 ? items.length - 1 : index >= items.length ? 0 : index;
|
|
if (controlledIndex === undefined) {
|
|
setCurrentIndex(newIndex);
|
|
}
|
|
onSlide?.(newIndex);
|
|
}, [items.length, controlledIndex, onSlide]);
|
|
|
|
const goToPrev = useCallback(() => {
|
|
goToSlide(activeIndex - 1);
|
|
}, [activeIndex, goToSlide]);
|
|
|
|
const goToNext = useCallback(() => {
|
|
goToSlide(activeIndex + 1);
|
|
}, [activeIndex, goToSlide]);
|
|
|
|
// Auto play
|
|
useEffect(() => {
|
|
if (!autoPlay || isPaused) return;
|
|
|
|
const timer = setInterval(() => {
|
|
goToNext();
|
|
}, interval);
|
|
|
|
return () => clearInterval(timer);
|
|
}, [autoPlay, isPaused, interval, goToNext]);
|
|
|
|
// Keyboard navigation
|
|
useEffect(() => {
|
|
if (!keyboard) return;
|
|
|
|
const handleKeydown = (e: KeyboardEvent) => {
|
|
if (e.key === 'ArrowLeft') {
|
|
goToPrev();
|
|
} else if (e.key === 'ArrowRight') {
|
|
goToNext();
|
|
}
|
|
};
|
|
|
|
const carousel = carouselRef.current;
|
|
carousel?.addEventListener('keydown', handleKeydown);
|
|
return () => carousel?.removeEventListener('keydown', handleKeydown);
|
|
}, [keyboard, goToPrev, goToNext]);
|
|
|
|
// Touch handling
|
|
const handleTouchStart = (e: React.TouchEvent) => {
|
|
if (!touch) return;
|
|
setTouchStart(e.touches[0].clientX);
|
|
};
|
|
|
|
const handleTouchEnd = (e: React.TouchEvent) => {
|
|
if (!touch || touchStart === null) return;
|
|
|
|
const touchEnd = e.changedTouches[0].clientX;
|
|
const diff = touchStart - touchEnd;
|
|
|
|
if (Math.abs(diff) > 50) {
|
|
if (diff > 0) {
|
|
goToNext();
|
|
} else {
|
|
goToPrev();
|
|
}
|
|
}
|
|
setTouchStart(null);
|
|
};
|
|
|
|
const classes = [
|
|
'll-carousel',
|
|
fade && 'll-carousel-fade',
|
|
dark && 'll-carousel-dark',
|
|
className,
|
|
].filter(Boolean).join(' ');
|
|
|
|
return (
|
|
<div
|
|
ref={carouselRef}
|
|
className={classes}
|
|
tabIndex={keyboard ? 0 : undefined}
|
|
onMouseEnter={() => pauseOnHover && setIsPaused(true)}
|
|
onMouseLeave={() => pauseOnHover && setIsPaused(false)}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchEnd={handleTouchEnd}
|
|
>
|
|
{/* Indicators */}
|
|
{indicators && items.length > 1 && (
|
|
<div className="ll-carousel-indicators">
|
|
{items.map((_, index) => (
|
|
<button
|
|
key={index}
|
|
type="button"
|
|
className={`ll-carousel-indicator ${index === activeIndex ? 'active' : ''}`}
|
|
onClick={() => goToSlide(index)}
|
|
aria-current={index === activeIndex}
|
|
aria-label={`Slide ${index + 1}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Slides */}
|
|
<div className="ll-carousel-inner">
|
|
{items.map((item, index) => (
|
|
<div
|
|
key={index}
|
|
className={`ll-carousel-item ${index === activeIndex ? 'active' : ''}`}
|
|
>
|
|
{item.content || (
|
|
<img
|
|
src={item.src}
|
|
alt={item.alt || ''}
|
|
className="ll-carousel-image"
|
|
/>
|
|
)}
|
|
{(item.title || item.description) && (
|
|
<div className="ll-carousel-caption">
|
|
{item.title && <h5>{item.title}</h5>}
|
|
{item.description && <p>{item.description}</p>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
{controls && items.length > 1 && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="ll-carousel-control ll-carousel-control-prev"
|
|
onClick={goToPrev}
|
|
aria-label="Previous"
|
|
>
|
|
<span className="ll-carousel-control-icon ll-carousel-control-prev-icon" aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ll-carousel-control ll-carousel-control-next"
|
|
onClick={goToNext}
|
|
aria-label="Next"
|
|
>
|
|
<span className="ll-carousel-control-icon ll-carousel-control-next-icon" aria-hidden="true" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|