@moontra/moonui 3.0.0 → 3.1.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.
@@ -0,0 +1,228 @@
1
+ import React from 'react'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import {
4
+ InputOTP,
5
+ InputOTPGroup,
6
+ InputOTPSlot,
7
+ InputOTPSeparator,
8
+ inputOTPSlotVariants,
9
+ REGEXP_ONLY_DIGITS,
10
+ } from '../input-otp'
11
+
12
+ // Kontrollü test bileşeni — gerçek input-otp kütüphanesiyle uçtan uca çalışır
13
+ const ControlledOTP = ({
14
+ onChange,
15
+ maxLength = 6,
16
+ ...props
17
+ }: {
18
+ onChange?: (value: string) => void
19
+ maxLength?: number
20
+ } & Partial<React.ComponentProps<typeof InputOTP>>) => {
21
+ const [value, setValue] = React.useState('')
22
+ return (
23
+ <InputOTP
24
+ maxLength={maxLength}
25
+ value={value}
26
+ onChange={(newValue) => {
27
+ setValue(newValue)
28
+ onChange?.(newValue)
29
+ }}
30
+ {...props}
31
+ >
32
+ <InputOTPGroup data-testid="otp-group">
33
+ {Array.from({ length: maxLength }, (_, i) => (
34
+ <InputOTPSlot key={i} index={i} data-testid={`otp-slot-${i}`} />
35
+ ))}
36
+ </InputOTPGroup>
37
+ </InputOTP>
38
+ )
39
+ }
40
+
41
+ describe('InputOTP Components', () => {
42
+ describe('InputOTP Root', () => {
43
+ it('renders a textbox with the given maxLength', () => {
44
+ render(<ControlledOTP maxLength={6} />)
45
+
46
+ const input = screen.getByRole('textbox')
47
+ expect(input).toBeInTheDocument()
48
+ expect(input).toHaveAttribute('maxlength', '6')
49
+ })
50
+
51
+ it('applies moonui-theme to the container', () => {
52
+ const { container } = render(<ControlledOTP />)
53
+
54
+ expect(container.querySelector('.moonui-theme')).toBeInTheDocument()
55
+ })
56
+
57
+ it('merges custom containerClassName', () => {
58
+ const { container } = render(
59
+ <ControlledOTP containerClassName="custom-container" />
60
+ )
61
+
62
+ const otpContainer = container.querySelector('.custom-container')
63
+ expect(otpContainer).toBeInTheDocument()
64
+ expect(otpContainer).toHaveClass('flex', 'items-center', 'gap-2')
65
+ })
66
+
67
+ it('forwards ref to the underlying input', () => {
68
+ const ref = React.createRef<HTMLInputElement>()
69
+ render(
70
+ <InputOTP ref={ref} maxLength={4}>
71
+ <InputOTPGroup>
72
+ <InputOTPSlot index={0} />
73
+ </InputOTPGroup>
74
+ </InputOTP>
75
+ )
76
+
77
+ expect(ref.current).toBeInstanceOf(HTMLInputElement)
78
+ })
79
+
80
+ it('maintains displayName', () => {
81
+ expect(InputOTP.displayName).toBe('InputOTP')
82
+ expect(InputOTPGroup.displayName).toBe('InputOTPGroup')
83
+ expect(InputOTPSlot.displayName).toBe('InputOTPSlot')
84
+ expect(InputOTPSeparator.displayName).toBe('InputOTPSeparator')
85
+ })
86
+
87
+ it('passes through aria-label to the input', () => {
88
+ render(<ControlledOTP aria-label="One-time password" />)
89
+
90
+ expect(screen.getByRole('textbox')).toHaveAttribute(
91
+ 'aria-label',
92
+ 'One-time password'
93
+ )
94
+ })
95
+
96
+ it('disables the input when disabled prop is set', () => {
97
+ render(
98
+ <InputOTP maxLength={4} disabled>
99
+ <InputOTPGroup>
100
+ <InputOTPSlot index={0} />
101
+ </InputOTPGroup>
102
+ </InputOTP>
103
+ )
104
+
105
+ expect(screen.getByRole('textbox')).toBeDisabled()
106
+ })
107
+ })
108
+
109
+ describe('Value Entry', () => {
110
+ it('calls onChange and renders characters into slots when typing', () => {
111
+ const handleChange = jest.fn()
112
+ render(<ControlledOTP onChange={handleChange} />)
113
+
114
+ const input = screen.getByRole('textbox')
115
+ fireEvent.change(input, { target: { value: '123' } })
116
+
117
+ expect(handleChange).toHaveBeenLastCalledWith('123')
118
+ expect(screen.getByTestId('otp-slot-0')).toHaveTextContent('1')
119
+ expect(screen.getByTestId('otp-slot-1')).toHaveTextContent('2')
120
+ expect(screen.getByTestId('otp-slot-2')).toHaveTextContent('3')
121
+ expect(screen.getByTestId('otp-slot-3')).toHaveTextContent('')
122
+ })
123
+
124
+ it('supports pasting a full code at once', () => {
125
+ const handleChange = jest.fn()
126
+ render(<ControlledOTP onChange={handleChange} />)
127
+
128
+ // Paste davranışı: input değeri tek seferde tamamen değişir
129
+ const input = screen.getByRole('textbox')
130
+ fireEvent.change(input, { target: { value: '123456' } })
131
+
132
+ expect(handleChange).toHaveBeenLastCalledWith('123456')
133
+ expect(screen.getByTestId('otp-slot-5')).toHaveTextContent('6')
134
+ })
135
+
136
+ it('does not exceed maxLength', () => {
137
+ const handleChange = jest.fn()
138
+ render(<ControlledOTP maxLength={4} onChange={handleChange} />)
139
+
140
+ const input = screen.getByRole('textbox')
141
+ fireEvent.change(input, { target: { value: '12345' } })
142
+
143
+ // input-otp maxLength üstünü keser veya reddeder — 5 karakterlik değer asla yayılmaz
144
+ expect(handleChange).not.toHaveBeenCalledWith('12345')
145
+ })
146
+
147
+ it('rejects non-matching characters when pattern is digits-only', () => {
148
+ const handleChange = jest.fn()
149
+ render(<ControlledOTP pattern={REGEXP_ONLY_DIGITS} onChange={handleChange} />)
150
+
151
+ const input = screen.getByRole('textbox')
152
+ fireEvent.change(input, { target: { value: 'abc' } })
153
+
154
+ expect(handleChange).not.toHaveBeenCalled()
155
+ })
156
+ })
157
+
158
+ describe('InputOTPSlot', () => {
159
+ it('renders base styles with token-based border classes', () => {
160
+ render(<ControlledOTP />)
161
+
162
+ const slot = screen.getByTestId('otp-slot-0')
163
+ expect(slot).toHaveClass('border-y', 'border-r', 'border-input')
164
+ })
165
+
166
+ it('applies custom className', () => {
167
+ render(
168
+ <InputOTP maxLength={2}>
169
+ <InputOTPGroup>
170
+ <InputOTPSlot index={0} className="custom-slot" data-testid="slot" />
171
+ </InputOTPGroup>
172
+ </InputOTP>
173
+ )
174
+
175
+ expect(screen.getByTestId('slot')).toHaveClass('custom-slot')
176
+ })
177
+
178
+ it('marks the active slot with data-active and ring styles on focus', () => {
179
+ render(<ControlledOTP />)
180
+
181
+ const input = screen.getByRole('textbox')
182
+ fireEvent.focus(input)
183
+
184
+ const slot = screen.getByTestId('otp-slot-0')
185
+ expect(slot).toHaveAttribute('data-active')
186
+ expect(slot).toHaveClass('ring-2', 'ring-ring')
187
+ })
188
+
189
+ it('exposes active ring styling through inputOTPSlotVariants', () => {
190
+ // Aktif slot vurgusu token tabanlı ring sınıflarını içermeli
191
+ expect(inputOTPSlotVariants({ isActive: true })).toContain('ring-2')
192
+ expect(inputOTPSlotVariants({ isActive: true })).toContain('ring-ring')
193
+ expect(inputOTPSlotVariants({ isActive: false })).not.toContain('ring-2')
194
+ })
195
+ })
196
+
197
+ describe('InputOTPSeparator', () => {
198
+ it('renders with separator role', () => {
199
+ render(
200
+ <InputOTP maxLength={4}>
201
+ <InputOTPGroup>
202
+ <InputOTPSlot index={0} />
203
+ </InputOTPGroup>
204
+ <InputOTPSeparator data-testid="separator" />
205
+ <InputOTPGroup>
206
+ <InputOTPSlot index={1} />
207
+ </InputOTPGroup>
208
+ </InputOTP>
209
+ )
210
+
211
+ const separator = screen.getByTestId('separator')
212
+ expect(separator).toHaveAttribute('role', 'separator')
213
+ })
214
+
215
+ it('applies custom className', () => {
216
+ render(
217
+ <InputOTP maxLength={2}>
218
+ <InputOTPGroup>
219
+ <InputOTPSlot index={0} />
220
+ </InputOTPGroup>
221
+ <InputOTPSeparator className="custom-separator" data-testid="separator" />
222
+ </InputOTP>
223
+ )
224
+
225
+ expect(screen.getByTestId('separator')).toHaveClass('custom-separator')
226
+ })
227
+ })
228
+ })
@@ -0,0 +1,324 @@
1
+ "use client"
2
+
3
+ import * as React from "react";
4
+ import useEmblaCarousel, {
5
+ type UseEmblaCarouselType,
6
+ } from "embla-carousel-react";
7
+ import { cva, type VariantProps } from "class-variance-authority";
8
+ import { ArrowLeft, ArrowRight } from "lucide-react";
9
+
10
+ import { cn } from "../../lib/utils";
11
+ import { Button } from "./button";
12
+
13
+ /**
14
+ * Premium Carousel Component
15
+ *
16
+ * Embla Carousel tabanlı, erişilebilir ve esnek carousel bileşeni.
17
+ * Yatay/dikey yön desteği, klavye navigasyonu ve plugin (autoplay vb.) desteği sunar.
18
+ */
19
+
20
+ type CarouselApi = UseEmblaCarouselType[1];
21
+ type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
22
+ type CarouselOptions = UseCarouselParameters[0];
23
+ type CarouselPlugin = UseCarouselParameters[1];
24
+
25
+ export interface CarouselProps extends React.HTMLAttributes<HTMLDivElement> {
26
+ /** Embla carousel seçenekleri (loop, align, axis vb.) */
27
+ opts?: CarouselOptions;
28
+ /** Embla plugin listesi (ör. autoplay) */
29
+ plugins?: CarouselPlugin;
30
+ /** Kaydırma yönü */
31
+ orientation?: "horizontal" | "vertical";
32
+ /** Embla API'sine dışarıdan erişmek için callback */
33
+ setApi?: (api: CarouselApi) => void;
34
+ }
35
+
36
+ interface CarouselContextProps
37
+ extends Pick<CarouselProps, "opts" | "plugins" | "setApi"> {
38
+ carouselRef: ReturnType<typeof useEmblaCarousel>[0];
39
+ api: ReturnType<typeof useEmblaCarousel>[1];
40
+ scrollPrev: () => void;
41
+ scrollNext: () => void;
42
+ canScrollPrev: boolean;
43
+ canScrollNext: boolean;
44
+ orientation: "horizontal" | "vertical";
45
+ }
46
+
47
+ const CarouselContext = React.createContext<CarouselContextProps | null>(null);
48
+
49
+ /**
50
+ * Carousel context hook'u — Carousel alt bileşenlerinin embla API'sine
51
+ * ve yön bilgisine erişmesini sağlar.
52
+ */
53
+ function useCarousel() {
54
+ const context = React.useContext(CarouselContext);
55
+
56
+ if (!context) {
57
+ throw new Error("useCarousel must be used within a <Carousel />");
58
+ }
59
+
60
+ return context;
61
+ }
62
+
63
+ /* -------------------------------------------------------------------------------------------------
64
+ * Carousel Root
65
+ * -----------------------------------------------------------------------------------------------*/
66
+ const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
67
+ (
68
+ {
69
+ orientation = "horizontal",
70
+ opts,
71
+ setApi,
72
+ plugins,
73
+ className,
74
+ children,
75
+ ...props
76
+ },
77
+ ref
78
+ ) => {
79
+ // Yön bilgisi opts.axis ile de verilebilir — orientation prop'u öncelikli
80
+ const resolvedOrientation =
81
+ orientation || (opts?.axis === "y" ? "vertical" : "horizontal");
82
+
83
+ const [carouselRef, api] = useEmblaCarousel(
84
+ {
85
+ ...opts,
86
+ axis: resolvedOrientation === "horizontal" ? "x" : "y",
87
+ },
88
+ plugins
89
+ );
90
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false);
91
+ const [canScrollNext, setCanScrollNext] = React.useState(false);
92
+
93
+ // Embla "select" olayında ileri/geri butonlarının durumunu güncelle
94
+ const onSelect = React.useCallback((emblaApi: CarouselApi) => {
95
+ if (!emblaApi) return;
96
+ setCanScrollPrev(emblaApi.canScrollPrev());
97
+ setCanScrollNext(emblaApi.canScrollNext());
98
+ }, []);
99
+
100
+ const scrollPrev = React.useCallback(() => {
101
+ api?.scrollPrev();
102
+ }, [api]);
103
+
104
+ const scrollNext = React.useCallback(() => {
105
+ api?.scrollNext();
106
+ }, [api]);
107
+
108
+ // Klavye navigasyonu: yatayda sol/sağ, dikeyde yukarı/aşağı ok tuşları
109
+ const handleKeyDown = React.useCallback(
110
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
111
+ const prevKey =
112
+ resolvedOrientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
113
+ const nextKey =
114
+ resolvedOrientation === "horizontal" ? "ArrowRight" : "ArrowDown";
115
+
116
+ if (event.key === prevKey) {
117
+ event.preventDefault();
118
+ scrollPrev();
119
+ } else if (event.key === nextKey) {
120
+ event.preventDefault();
121
+ scrollNext();
122
+ }
123
+ },
124
+ [resolvedOrientation, scrollPrev, scrollNext]
125
+ );
126
+
127
+ React.useEffect(() => {
128
+ if (!api || !setApi) return;
129
+ setApi(api);
130
+ }, [api, setApi]);
131
+
132
+ React.useEffect(() => {
133
+ if (!api) return;
134
+
135
+ onSelect(api);
136
+ api.on("reInit", onSelect);
137
+ api.on("select", onSelect);
138
+
139
+ return () => {
140
+ api.off("reInit", onSelect);
141
+ api.off("select", onSelect);
142
+ };
143
+ }, [api, onSelect]);
144
+
145
+ return (
146
+ <CarouselContext.Provider
147
+ value={{
148
+ carouselRef,
149
+ api,
150
+ opts,
151
+ scrollPrev,
152
+ scrollNext,
153
+ canScrollPrev,
154
+ canScrollNext,
155
+ orientation: resolvedOrientation,
156
+ }}
157
+ >
158
+ <div
159
+ ref={ref}
160
+ onKeyDownCapture={handleKeyDown}
161
+ className={cn("moonui-theme", "relative", className)}
162
+ role="region"
163
+ aria-roledescription="carousel"
164
+ {...props}
165
+ >
166
+ {children}
167
+ </div>
168
+ </CarouselContext.Provider>
169
+ );
170
+ }
171
+ );
172
+ Carousel.displayName = "Carousel";
173
+
174
+ /* -------------------------------------------------------------------------------------------------
175
+ * CarouselContent
176
+ * -----------------------------------------------------------------------------------------------*/
177
+ const carouselContentVariants = cva("flex", {
178
+ variants: {
179
+ orientation: {
180
+ horizontal: "-ml-4",
181
+ vertical: "-mt-4 flex-col",
182
+ },
183
+ },
184
+ defaultVariants: {
185
+ orientation: "horizontal",
186
+ },
187
+ });
188
+
189
+ export interface CarouselContentProps
190
+ extends React.HTMLAttributes<HTMLDivElement>,
191
+ Omit<VariantProps<typeof carouselContentVariants>, "orientation"> {}
192
+
193
+ const CarouselContent = React.forwardRef<HTMLDivElement, CarouselContentProps>(
194
+ ({ className, ...props }, ref) => {
195
+ const { carouselRef, orientation } = useCarousel();
196
+
197
+ return (
198
+ <div ref={carouselRef} className="overflow-hidden">
199
+ <div
200
+ ref={ref}
201
+ className={cn(carouselContentVariants({ orientation }), className)}
202
+ {...props}
203
+ />
204
+ </div>
205
+ );
206
+ }
207
+ );
208
+ CarouselContent.displayName = "CarouselContent";
209
+
210
+ /* -------------------------------------------------------------------------------------------------
211
+ * CarouselItem
212
+ * -----------------------------------------------------------------------------------------------*/
213
+ const carouselItemVariants = cva("min-w-0 shrink-0 grow-0 basis-full", {
214
+ variants: {
215
+ orientation: {
216
+ horizontal: "pl-4",
217
+ vertical: "pt-4",
218
+ },
219
+ },
220
+ defaultVariants: {
221
+ orientation: "horizontal",
222
+ },
223
+ });
224
+
225
+ export interface CarouselItemProps
226
+ extends React.HTMLAttributes<HTMLDivElement>,
227
+ Omit<VariantProps<typeof carouselItemVariants>, "orientation"> {}
228
+
229
+ const CarouselItem = React.forwardRef<HTMLDivElement, CarouselItemProps>(
230
+ ({ className, ...props }, ref) => {
231
+ const { orientation } = useCarousel();
232
+
233
+ return (
234
+ <div
235
+ ref={ref}
236
+ role="group"
237
+ aria-roledescription="slide"
238
+ className={cn(carouselItemVariants({ orientation }), className)}
239
+ {...props}
240
+ />
241
+ );
242
+ }
243
+ );
244
+ CarouselItem.displayName = "CarouselItem";
245
+
246
+ /* -------------------------------------------------------------------------------------------------
247
+ * CarouselPrevious
248
+ * -----------------------------------------------------------------------------------------------*/
249
+ const CarouselPrevious = React.forwardRef<
250
+ HTMLButtonElement,
251
+ React.ComponentProps<typeof Button>
252
+ >(({ className, variant = "outline", size = "icon-sm", ...props }, ref) => {
253
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
254
+
255
+ return (
256
+ <Button
257
+ ref={ref}
258
+ variant={variant}
259
+ size={size}
260
+ rounded="full"
261
+ className={cn(
262
+ "absolute",
263
+ orientation === "horizontal"
264
+ ? "-left-12 top-1/2 -translate-y-1/2"
265
+ : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
266
+ className
267
+ )}
268
+ disabled={!canScrollPrev}
269
+ onClick={scrollPrev}
270
+ {...props}
271
+ >
272
+ <ArrowLeft className="h-4 w-4" aria-hidden="true" />
273
+ <span className="sr-only">Previous slide</span>
274
+ </Button>
275
+ );
276
+ });
277
+ CarouselPrevious.displayName = "CarouselPrevious";
278
+
279
+ /* -------------------------------------------------------------------------------------------------
280
+ * CarouselNext
281
+ * -----------------------------------------------------------------------------------------------*/
282
+ const CarouselNext = React.forwardRef<
283
+ HTMLButtonElement,
284
+ React.ComponentProps<typeof Button>
285
+ >(({ className, variant = "outline", size = "icon-sm", ...props }, ref) => {
286
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
287
+
288
+ return (
289
+ <Button
290
+ ref={ref}
291
+ variant={variant}
292
+ size={size}
293
+ rounded="full"
294
+ className={cn(
295
+ "absolute",
296
+ orientation === "horizontal"
297
+ ? "-right-12 top-1/2 -translate-y-1/2"
298
+ : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
299
+ className
300
+ )}
301
+ disabled={!canScrollNext}
302
+ onClick={scrollNext}
303
+ {...props}
304
+ >
305
+ <ArrowRight className="h-4 w-4" aria-hidden="true" />
306
+ <span className="sr-only">Next slide</span>
307
+ </Button>
308
+ );
309
+ });
310
+ CarouselNext.displayName = "CarouselNext";
311
+
312
+ export {
313
+ type CarouselApi,
314
+ type CarouselOptions,
315
+ type CarouselPlugin,
316
+ Carousel,
317
+ CarouselContent,
318
+ CarouselItem,
319
+ CarouselPrevious,
320
+ CarouselNext,
321
+ useCarousel,
322
+ carouselContentVariants,
323
+ carouselItemVariants,
324
+ };
@@ -98,6 +98,23 @@ export {
98
98
  CardZipInput as MoonUICardZipInput,
99
99
  } from "./card-input";
100
100
 
101
+ // Carousel
102
+ export {
103
+ Carousel as MoonUICarousel,
104
+ CarouselContent as MoonUICarouselContent,
105
+ CarouselItem as MoonUICarouselItem,
106
+ CarouselPrevious as MoonUICarouselPrevious,
107
+ CarouselNext as MoonUICarouselNext,
108
+ carouselContentVariants as moonUICarouselContentVariants,
109
+ carouselItemVariants as moonUICarouselItemVariants,
110
+ useCarousel,
111
+ } from "./carousel";
112
+
113
+ export type {
114
+ CarouselProps as MoonUICarouselProps,
115
+ CarouselApi as MoonUICarouselApi,
116
+ } from "./carousel";
117
+
101
118
  // Checkbox
102
119
  export {
103
120
  Checkbox as MoonUICheckbox,
@@ -220,6 +237,19 @@ export type {
220
237
  InputProps as MoonUIInputProps,
221
238
  } from "./input";
222
239
 
240
+ // InputOTP
241
+ export {
242
+ InputOTP as MoonUIInputOTP,
243
+ InputOTPGroup as MoonUIInputOTPGroup,
244
+ InputOTPSlot as MoonUIInputOTPSlot,
245
+ InputOTPSeparator as MoonUIInputOTPSeparator,
246
+ inputOTPSlotVariants as moonUIInputOTPSlotVariants,
247
+ } from "./input-otp";
248
+
249
+ export type {
250
+ InputOTPProps as MoonUIInputOTPProps,
251
+ } from "./input-otp";
252
+
223
253
  // Label
224
254
  export { Label as MoonUILabel } from "./label";
225
255
 
@@ -404,6 +434,7 @@ export * from "./breadcrumb";
404
434
  export * from "./button";
405
435
  export * from "./card";
406
436
  export * from "./card-input";
437
+ export * from "./carousel";
407
438
  export * from "./checkbox";
408
439
  export * from "./collapsible";
409
440
  export * from "./color-picker";
@@ -416,6 +447,7 @@ export * from "./file-upload";
416
447
  export * from "./gesture-drawer";
417
448
  export * from "./github-stars";
418
449
  export * from "./input";
450
+ export * from "./input-otp";
419
451
  export * from "./label";
420
452
  export * from "./locked-component";
421
453
  export * from "./moon-logo";