@moontra/moonui 5.0.0 → 5.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.
Files changed (43) hide show
  1. package/dist/hooks/index.d.mts +15 -0
  2. package/dist/hooks/index.d.ts +15 -0
  3. package/dist/hooks/index.global.js +38 -0
  4. package/dist/hooks/index.global.js.map +1 -0
  5. package/dist/hooks/index.js +128 -0
  6. package/dist/hooks/index.js.map +1 -0
  7. package/dist/hooks/index.mjs +106 -0
  8. package/dist/hooks/index.mjs.map +1 -0
  9. package/dist/index.d.mts +2 -2
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.global.js +9 -9
  12. package/dist/index.global.js.map +1 -1
  13. package/dist/index.js +17 -11
  14. package/dist/index.js.map +1 -1
  15. package/dist/index.mjs +17 -11
  16. package/dist/index.mjs.map +1 -1
  17. package/dist/lib/theme.global.js.map +1 -1
  18. package/package.json +8 -3
  19. package/src/__tests__/hooks-exports-contract.test.ts +22 -0
  20. package/src/__tests__/use-intersection-observer.test.tsx +18 -4
  21. package/src/__tests__/use-local-storage.test.tsx +34 -20
  22. package/src/components/ui/__tests__/avatar.test.tsx +5 -7
  23. package/src/components/ui/__tests__/badge.test.tsx +19 -12
  24. package/src/components/ui/__tests__/breadcrumb.test.tsx +4 -7
  25. package/src/components/ui/__tests__/button.test.tsx +6 -3
  26. package/src/components/ui/__tests__/card.test.tsx +4 -1
  27. package/src/components/ui/__tests__/checkbox.test.tsx +4 -1
  28. package/src/components/ui/__tests__/command.test.tsx +268 -124
  29. package/src/components/ui/__tests__/data-table-basic.test.tsx +271 -0
  30. package/src/components/ui/__tests__/date-picker.test.tsx +184 -197
  31. package/src/components/ui/__tests__/progress.test.tsx +0 -1
  32. package/src/components/ui/__tests__/table.test.tsx +8 -2
  33. package/src/components/ui/__tests__/tabs.test.tsx +5 -3
  34. package/src/components/ui/__tests__/toast.test.tsx +461 -159
  35. package/src/components/ui/__tests__/tooltip.test.tsx +387 -264
  36. package/src/components/ui/command.tsx +11 -7
  37. package/src/components/ui/date-picker.tsx +12 -6
  38. package/src/components/ui/toast.tsx +3 -0
  39. package/src/hooks/index.ts +24 -0
  40. package/src/use-intersection-observer.tsx +10 -1
  41. package/src/use-local-storage.tsx +35 -1
  42. package/src/components/ui/__tests__/data-table.test.tsx +0 -256
  43. package/src/components/ui/data-table.stories.tsx +0 -511
@@ -52,7 +52,11 @@ const Command = React.forwardRef<
52
52
  {...props}
53
53
  />
54
54
  ))
55
- Command.displayName = CommandPrimitive.displayName
55
+ // NOT: cmdk primitive'leri displayName tanımlamaz (Radix'ten farklı olarak). Bu yüzden
56
+ // `CommandPrimitive.*.displayName` okumak `undefined` üretiyordu ve React DevTools ile hata
57
+ // mesajlarında bileşenler isimsiz görünüyordu. Kütüphane genelindeki konvansiyona uyarak
58
+ // displayName'ler açık string olarak verilir.
59
+ Command.displayName = "Command"
56
60
 
57
61
  // CommandDialog bileşeni
58
62
  interface CommandDialogProps extends React.ComponentProps<typeof Dialog> {
@@ -100,7 +104,7 @@ const CommandInput = React.forwardRef<
100
104
  />
101
105
  </div>
102
106
  ))
103
- CommandInput.displayName = CommandPrimitive.Input.displayName
107
+ CommandInput.displayName = "CommandInput"
104
108
 
105
109
  // CommandList bileşeni
106
110
  interface CommandListProps
@@ -116,7 +120,7 @@ const CommandList = React.forwardRef<
116
120
  {...props}
117
121
  />
118
122
  ))
119
- CommandList.displayName = CommandPrimitive.List.displayName
123
+ CommandList.displayName = "CommandList"
120
124
 
121
125
  // CommandEmpty bileşeni
122
126
  interface CommandEmptyProps
@@ -132,7 +136,7 @@ const CommandEmpty = React.forwardRef<
132
136
  {...props}
133
137
  />
134
138
  ))
135
- CommandEmpty.displayName = CommandPrimitive.Empty.displayName
139
+ CommandEmpty.displayName = "CommandEmpty"
136
140
 
137
141
  // CommandGroup bileşeni
138
142
  interface CommandGroupProps
@@ -151,7 +155,7 @@ const CommandGroup = React.forwardRef<
151
155
  {...props}
152
156
  />
153
157
  ))
154
- CommandGroup.displayName = CommandPrimitive.Group.displayName
158
+ CommandGroup.displayName = "CommandGroup"
155
159
 
156
160
  // CommandSeparator bileşeni
157
161
  interface CommandSeparatorProps
@@ -167,7 +171,7 @@ const CommandSeparator = React.forwardRef<
167
171
  {...props}
168
172
  />
169
173
  ))
170
- CommandSeparator.displayName = CommandPrimitive.Separator.displayName
174
+ CommandSeparator.displayName = "CommandSeparator"
171
175
 
172
176
  // CommandItem bileşeni
173
177
  interface CommandItemProps
@@ -186,7 +190,7 @@ const CommandItem = React.forwardRef<
186
190
  {...props}
187
191
  />
188
192
  ))
189
- CommandItem.displayName = CommandPrimitive.Item.displayName
193
+ CommandItem.displayName = "CommandItem"
190
194
 
191
195
  // CommandShortcut bileşeni - utility component
192
196
  const CommandShortcut = ({
@@ -312,11 +312,11 @@ export interface DateRangePickerProps
312
312
  /**
313
313
  * Selected date range
314
314
  */
315
- value?: { from: Date | undefined; to: Date | undefined };
315
+ value?: { from: Date | undefined; to?: Date | undefined };
316
316
  /**
317
317
  * Callback when date range changes
318
318
  */
319
- onChange?: (range: { from: Date | undefined; to: Date | undefined } | undefined) => void;
319
+ onChange?: (range: { from: Date | undefined; to?: Date | undefined } | undefined) => void;
320
320
  /**
321
321
  * Separator between dates
322
322
  */
@@ -339,7 +339,10 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
339
339
  setInternalRange(value);
340
340
  }, [value]);
341
341
 
342
- const handleSelect = (range: { from: Date | undefined; to: Date | undefined } | undefined) => {
342
+ // NOT: `to` OPSİYONEL. react-day-picker'ın `DateRange` tipi de öyle
343
+ // (`to?: Date | undefined`). Önce burada zorunlu-ama-undefined yazılmıştı ve
344
+ // uyuşmadığı için `onSelect={handleSelect}` cast'i gerekiyordu.
345
+ const handleSelect = (range: { from: Date | undefined; to?: Date | undefined } | undefined) => {
343
346
  setInternalRange(range);
344
347
  onChange?.(range);
345
348
  if (range?.from && range?.to) {
@@ -386,7 +389,7 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
386
389
  mode="range"
387
390
  defaultMonth={internalRange?.from}
388
391
  selected={internalRange}
389
- onSelect={handleSelect as any}
392
+ onSelect={handleSelect}
390
393
  showOutsideDays={false}
391
394
  {...props.calendarProps}
392
395
  className="rounded-2xl"
@@ -421,7 +424,10 @@ export interface DateTimePickerProps extends DatePickerProps {
421
424
  export const DateTimePicker = React.forwardRef<HTMLDivElement, DateTimePickerProps>(function DateTimePicker({
422
425
  value,
423
426
  onChange,
424
- formatString = "PPP p",
427
+ // Saat ayrı bir <select> ile gösterildiği için buton yalnızca TARİHİ yazar;
428
+ // varsayılan bu yüzden "PPP p" değil "PPP". (Eski hâlde "PPP p" alınıp hiç
429
+ // kullanılmıyordu — çağıranın verdiği formatString sessizce yok sayılıyordu.)
430
+ formatString = "PPP",
425
431
  showTimePicker = true,
426
432
  timeFormat = "24",
427
433
  timeInterval = 15,
@@ -486,7 +492,7 @@ export const DateTimePicker = React.forwardRef<HTMLDivElement, DateTimePickerPro
486
492
  {...props}
487
493
  value={date}
488
494
  onChange={handleDateChange}
489
- formatString="PPP"
495
+ formatString={formatString}
490
496
  showTodayButton={false}
491
497
  />
492
498
  {showTimePicker && date && (
@@ -93,6 +93,9 @@ const ToastClose = React.forwardRef<
93
93
  {...props}
94
94
  >
95
95
  <X className="h-4 w-4" />
96
+ {/* İkon-only kapatma düğmesinin erişilebilir bir ismi olmalı (WCAG 4.1.2);
97
+ aynı desen dialog.tsx'teki kapatma düğmesinde de kullanılıyor. */}
98
+ <span className="sr-only">Close</span>
96
99
  </ToastPrimitives.Close>
97
100
  ));
98
101
  ToastClose.displayName = ToastPrimitives.Close.displayName;
@@ -0,0 +1,24 @@
1
+ "use client";
2
+
3
+ /**
4
+ * `@moontra/moonui/hooks` — genel amaçlı React hook'ları.
5
+ *
6
+ * NEDEN AYRI ALT-YOL (`./hooks`), ana barrel değil:
7
+ * `src/index.tsx` sözleşme gereği yalnızca iki bölüm içerir (`./components/ui`
8
+ * re-export'u + `cn`). Hook'ları oraya eklemek o sözleşmeyi bozardı, bu yüzden
9
+ * hook'lar kendi giriş noktasından yayımlanır:
10
+ *
11
+ * import { useLocalStorage } from "@moontra/moonui/hooks"
12
+ *
13
+ * KAPSAM: buraya yalnızca TESTİ OLAN hook'lar alınır. `src/` kökünde henüz
14
+ * dışa açılmamış başka yardımcılar da var (`LazyLoad`, `useLazyImageLoading`,
15
+ * `useIsInViewport`, `useMultipleIntersectionObserver`, `use-scroll-animation`,
16
+ * `use-performance-optimizer` ve `use-toast.ts`'in yetim kopyası). Test
17
+ * kapsamları olmadığı için public API'ye alınmadılar — eklenmeden önce test
18
+ * yazılmalı.
19
+ */
20
+
21
+ export { useIntersectionObserver } from "../use-intersection-observer";
22
+ export type { UseIntersectionObserverOptions } from "../use-intersection-observer";
23
+
24
+ export { useLocalStorage } from "../use-local-storage";
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from "react";
4
4
 
5
- interface UseIntersectionObserverOptions extends IntersectionObserverInit {
5
+ export interface UseIntersectionObserverOptions extends IntersectionObserverInit {
6
6
  freezeOnceVisible?: boolean;
7
7
  initialIsIntersecting?: boolean;
8
8
  onChange?: (isIntersecting: boolean, entry: IntersectionObserverEntry) => void;
@@ -27,6 +27,15 @@ export function useIntersectionObserver({
27
27
 
28
28
  const observer = new IntersectionObserver(
29
29
  ([entry]) => {
30
+ // freezeOnceVisible: bir kez görünür olduktan sonra state DONMALI.
31
+ // frozen guard'ı yalnızca yukarıdaki effect girişinde vardı; effect'in
32
+ // deps'i değişmediği için yeniden koşmuyor, dolayısıyla guard hiç
33
+ // devreye girmiyordu. Canlı observer bir sonraki entry'de
34
+ // setIsIntersecting(false) çağırıp donmayı bozuyordu.
35
+ if (frozen.current) {
36
+ return;
37
+ }
38
+
30
39
  const isElementIntersecting = entry.isIntersecting;
31
40
  setIsIntersecting(isElementIntersecting);
32
41
  onChange?.(isElementIntersecting, entry);
@@ -1,5 +1,20 @@
1
1
  import { useState, useEffect } from 'react'
2
2
 
3
+ /**
4
+ * Aynı sekmedeki diğer hook instance'larını haberdar eden özel olay.
5
+ *
6
+ * Native `storage` olayı YALNIZCA diğer sekme/pencerelerde tetiklenir — değeri
7
+ * yazan dokümanda hiç tetiklenmez. Bu yüzden aynı sayfada `useLocalStorage("k")`
8
+ * kullanan iki component birbirinden habersiz kalıyor, biri güncellenince
9
+ * diğeri bayat değeri göstermeye devam ediyordu.
10
+ */
11
+ const LOCAL_SYNC_EVENT = 'moonui:local-storage'
12
+
13
+ interface LocalSyncDetail {
14
+ key: string
15
+ value: unknown
16
+ }
17
+
3
18
  // Local storage hook with error handling and SSR support
4
19
  export function useLocalStorage<T>(
5
20
  key: string,
@@ -40,6 +55,13 @@ export function useLocalStorage<T>(
40
55
  } else {
41
56
  window.localStorage.setItem(key, JSON.stringify(valueToStore))
42
57
  }
58
+
59
+ // Aynı dokümandaki diğer instance'ları bilgilendir (bkz. LOCAL_SYNC_EVENT)
60
+ window.dispatchEvent(
61
+ new CustomEvent<LocalSyncDetail>(LOCAL_SYNC_EVENT, {
62
+ detail: { key, value: valueToStore },
63
+ })
64
+ )
43
65
  }
44
66
  } catch (error) {
45
67
  // A more advanced implementation would handle the error case
@@ -63,8 +85,20 @@ export function useLocalStorage<T>(
63
85
  }
64
86
  }
65
87
 
88
+ // Aynı sekmedeki kardeş instance'lar
89
+ const handleLocalSync = (e: Event) => {
90
+ const detail = (e as CustomEvent<LocalSyncDetail>).detail
91
+ if (detail && detail.key === key) {
92
+ setStoredValue(detail.value as T)
93
+ }
94
+ }
95
+
66
96
  window.addEventListener('storage', handleStorageChange)
67
- return () => window.removeEventListener('storage', handleStorageChange)
97
+ window.addEventListener(LOCAL_SYNC_EVENT, handleLocalSync)
98
+ return () => {
99
+ window.removeEventListener('storage', handleStorageChange)
100
+ window.removeEventListener(LOCAL_SYNC_EVENT, handleLocalSync)
101
+ }
68
102
  }, [key])
69
103
 
70
104
  return [storedValue, setValue]
@@ -1,256 +0,0 @@
1
- import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2
- import { DataTable } from '../data-table';
3
- import { ColumnDef } from '@tanstack/react-table';
4
- import '@testing-library/jest-dom';
5
-
6
- type TestData = {
7
- id: string;
8
- name: string;
9
- email: string;
10
- age: number;
11
- status: 'active' | 'inactive';
12
- };
13
-
14
- const mockData: TestData[] = [
15
- { id: '1', name: 'John Doe', email: 'john@example.com', age: 30, status: 'active' },
16
- { id: '2', name: 'Jane Smith', email: 'jane@example.com', age: 25, status: 'inactive' },
17
- { id: '3', name: 'Bob Johnson', email: 'bob@example.com', age: 35, status: 'active' },
18
- ];
19
-
20
- const mockColumns: ColumnDef<TestData>[] = [
21
- {
22
- accessorKey: 'name',
23
- header: 'Name',
24
- },
25
- {
26
- accessorKey: 'email',
27
- header: 'Email',
28
- },
29
- {
30
- accessorKey: 'age',
31
- header: 'Age',
32
- },
33
- {
34
- accessorKey: 'status',
35
- header: 'Status',
36
- },
37
- ];
38
-
39
- describe('DataTable', () => {
40
- it('renders correctly with basic data', () => {
41
- render(<DataTable columns={mockColumns} data={mockData} />);
42
-
43
- // Check headers
44
- expect(screen.getByText('Name')).toBeInTheDocument();
45
- expect(screen.getByText('Email')).toBeInTheDocument();
46
- expect(screen.getByText('Age')).toBeInTheDocument();
47
- expect(screen.getByText('Status')).toBeInTheDocument();
48
-
49
- // Check data rows
50
- expect(screen.getByText('John Doe')).toBeInTheDocument();
51
- expect(screen.getByText('john@example.com')).toBeInTheDocument();
52
- expect(screen.getByText('30')).toBeInTheDocument();
53
- expect(screen.getByText('active')).toBeInTheDocument();
54
- });
55
-
56
- it('renders empty state when no data', () => {
57
- render(<DataTable columns={mockColumns} data={[]} />);
58
- expect(screen.getByText('No results.')).toBeInTheDocument();
59
- });
60
-
61
- it('shows search input when searchable is true', () => {
62
- render(<DataTable columns={mockColumns} data={mockData} searchable />);
63
- expect(screen.getByPlaceholderText('Search all columns...')).toBeInTheDocument();
64
- });
65
-
66
- it('filters data based on search input', async () => {
67
- render(<DataTable columns={mockColumns} data={mockData} searchable />);
68
-
69
- const searchInput = screen.getByPlaceholderText('Search all columns...');
70
- fireEvent.change(searchInput, { target: { value: 'john' } });
71
-
72
- await waitFor(() => {
73
- expect(screen.getByText('John Doe')).toBeInTheDocument();
74
- expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument();
75
- });
76
- });
77
-
78
- it('shows filter button when filterable is true', () => {
79
- render(<DataTable columns={mockColumns} data={mockData} filterable />);
80
- expect(screen.getByText('Filter')).toBeInTheDocument();
81
- });
82
-
83
- it('shows export button when exportable is true', () => {
84
- render(<DataTable columns={mockColumns} data={mockData} exportable />);
85
- expect(screen.getByText('Export')).toBeInTheDocument();
86
- });
87
-
88
- it('handles pagination correctly', () => {
89
- const largeData = Array.from({ length: 25 }, (_, i) => ({
90
- id: `${i + 1}`,
91
- name: `User ${i + 1}`,
92
- email: `user${i + 1}@example.com`,
93
- age: 20 + i,
94
- status: i % 2 === 0 ? 'active' as const : 'inactive' as const,
95
- }));
96
-
97
- render(<DataTable columns={mockColumns} data={largeData} />);
98
-
99
- // Check pagination controls
100
- expect(screen.getByText('Previous')).toBeInTheDocument();
101
- expect(screen.getByText('Next')).toBeInTheDocument();
102
- expect(screen.getByText('1 of 3')).toBeInTheDocument(); // 25 items, 10 per page = 3 pages
103
- });
104
-
105
- it('handles sorting correctly', async () => {
106
- const sortableColumns: ColumnDef<TestData>[] = [
107
- {
108
- accessorKey: 'name',
109
- header: ({ column }) => (
110
- <button onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}>
111
- Name
112
- </button>
113
- ),
114
- },
115
- {
116
- accessorKey: 'age',
117
- header: ({ column }) => (
118
- <button onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}>
119
- Age
120
- </button>
121
- ),
122
- },
123
- ];
124
-
125
- render(<DataTable columns={sortableColumns} data={mockData} />);
126
-
127
- const nameHeader = screen.getByText('Name');
128
- fireEvent.click(nameHeader);
129
-
130
- await waitFor(() => {
131
- const rows = screen.getAllByRole('row');
132
- // First row should be header, second should be Bob Johnson (alphabetically first)
133
- expect(rows[1]).toHaveTextContent('Bob Johnson');
134
- });
135
- });
136
-
137
- it('handles row selection', () => {
138
- const selectableColumns: ColumnDef<TestData>[] = [
139
- {
140
- id: 'select',
141
- header: ({ table }) => (
142
- <input
143
- type="checkbox"
144
- checked={table.getIsAllPageRowsSelected()}
145
- onChange={(e) => table.toggleAllPageRowsSelected(e.target.checked)}
146
- />
147
- ),
148
- cell: ({ row }) => (
149
- <input
150
- type="checkbox"
151
- checked={row.getIsSelected()}
152
- onChange={(e) => row.toggleSelected(e.target.checked)}
153
- />
154
- ),
155
- },
156
- ...mockColumns,
157
- ];
158
-
159
- render(<DataTable columns={selectableColumns} data={mockData} />);
160
-
161
- const checkboxes = screen.getAllByRole('checkbox');
162
- expect(checkboxes).toHaveLength(4); // 1 header + 3 rows
163
-
164
- // Select first row
165
- fireEvent.click(checkboxes[1]);
166
- expect(checkboxes[1]).toBeChecked();
167
- });
168
-
169
- it('handles column visibility', () => {
170
- render(<DataTable columns={mockColumns} data={mockData} />);
171
-
172
- // All columns should be visible by default
173
- expect(screen.getByText('Name')).toBeInTheDocument();
174
- expect(screen.getByText('Email')).toBeInTheDocument();
175
- expect(screen.getByText('Age')).toBeInTheDocument();
176
- expect(screen.getByText('Status')).toBeInTheDocument();
177
- });
178
-
179
- it('shows loading state', () => {
180
- render(<DataTable columns={mockColumns} data={mockData} loading />);
181
- expect(screen.getByText('Loading...')).toBeInTheDocument();
182
- });
183
-
184
- it('handles custom cell rendering', () => {
185
- const customColumns: ColumnDef<TestData>[] = [
186
- {
187
- accessorKey: 'name',
188
- header: 'Name',
189
- cell: ({ row }) => (
190
- <div className="font-bold">{row.getValue('name')}</div>
191
- ),
192
- },
193
- {
194
- accessorKey: 'status',
195
- header: 'Status',
196
- cell: ({ row }) => {
197
- const status = row.getValue('status') as string;
198
- return (
199
- <span className={status === 'active' ? 'text-green-600' : 'text-red-600'}>
200
- {status}
201
- </span>
202
- );
203
- },
204
- },
205
- ];
206
-
207
- render(<DataTable columns={customColumns} data={mockData} />);
208
-
209
- // Check if custom rendering is applied
210
- const johnDoeCell = screen.getByText('John Doe');
211
- expect(johnDoeCell).toHaveClass('font-bold');
212
-
213
- const activeStatus = screen.getAllByText('active')[0];
214
- expect(activeStatus).toHaveClass('text-green-600');
215
- });
216
-
217
- it('handles empty columns gracefully', () => {
218
- render(<DataTable columns={[]} data={mockData} />);
219
- expect(screen.getByText('No results.')).toBeInTheDocument();
220
- });
221
-
222
- it('handles keyboard navigation', () => {
223
- render(<DataTable columns={mockColumns} data={mockData} />);
224
-
225
- const table = screen.getByRole('table');
226
- expect(table).toBeInTheDocument();
227
-
228
- // Table should be keyboard accessible
229
- fireEvent.keyDown(table, { key: 'Tab' });
230
- expect(table).toBeInTheDocument();
231
- });
232
-
233
- it('exports data when export button is clicked', async () => {
234
- // Mock the download functionality
235
- const mockCreateObjectURL = jest.fn();
236
- const mockRevokeObjectURL = jest.fn();
237
- global.URL.createObjectURL = mockCreateObjectURL;
238
- global.URL.revokeObjectURL = mockRevokeObjectURL;
239
-
240
- render(<DataTable columns={mockColumns} data={mockData} exportable />);
241
-
242
- const exportButton = screen.getByText('Export');
243
- fireEvent.click(exportButton);
244
-
245
- await waitFor(() => {
246
- expect(mockCreateObjectURL).toHaveBeenCalled();
247
- });
248
- });
249
-
250
- it('handles responsive design', () => {
251
- render(<DataTable columns={mockColumns} data={mockData} />);
252
-
253
- const tableContainer = screen.getByTestId('data-table-container');
254
- expect(tableContainer).toHaveClass('overflow-auto');
255
- });
256
- });