@object-ui/react 2.0.0 → 3.0.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +19 -0
- package/dist/SchemaRenderer.d.ts +20 -1
- package/dist/SchemaRenderer.js +76 -13
- package/dist/context/DndContext.d.ts +135 -0
- package/dist/context/DndContext.js +120 -0
- package/dist/context/NotificationContext.d.ts +113 -0
- package/dist/context/NotificationContext.js +130 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/hooks/index.d.ts +17 -0
- package/dist/hooks/index.js +17 -0
- package/dist/hooks/useAnimation.d.ts +46 -0
- package/dist/hooks/useAnimation.js +59 -0
- package/dist/hooks/useClientNotifications.d.ts +53 -0
- package/dist/hooks/useClientNotifications.js +190 -0
- package/dist/hooks/useColumnSummary.d.ts +38 -0
- package/dist/hooks/useColumnSummary.js +54 -0
- package/dist/hooks/useCrudShortcuts.d.ts +39 -0
- package/dist/hooks/useCrudShortcuts.js +96 -0
- package/dist/hooks/useDensityMode.d.ts +48 -0
- package/dist/hooks/useDensityMode.js +60 -0
- package/dist/hooks/useETagCache.d.ts +75 -0
- package/dist/hooks/useETagCache.js +306 -0
- package/dist/hooks/useFocusManagement.d.ts +89 -0
- package/dist/hooks/useFocusManagement.js +188 -0
- package/dist/hooks/useFocusTrap.d.ts +41 -0
- package/dist/hooks/useFocusTrap.js +103 -0
- package/dist/hooks/useKeyboardShortcuts.d.ts +49 -0
- package/dist/hooks/useKeyboardShortcuts.js +86 -0
- package/dist/hooks/useOffline.d.ts +96 -0
- package/dist/hooks/useOffline.js +185 -0
- package/dist/hooks/usePageTransition.d.ts +60 -0
- package/dist/hooks/usePageTransition.js +111 -0
- package/dist/hooks/usePerformance.d.ts +87 -0
- package/dist/hooks/usePerformance.js +165 -0
- package/dist/hooks/usePerformanceBudget.d.ts +78 -0
- package/dist/hooks/usePerformanceBudget.js +209 -0
- package/dist/hooks/useReducedMotion.d.ts +28 -0
- package/dist/hooks/useReducedMotion.js +48 -0
- package/dist/hooks/useSchemaPersistence.d.ts +87 -0
- package/dist/hooks/useSchemaPersistence.js +173 -0
- package/dist/hooks/useViewSharing.d.ts +57 -0
- package/dist/hooks/useViewSharing.js +74 -0
- package/dist/hooks/useViewTransition.d.ts +54 -0
- package/dist/hooks/useViewTransition.js +83 -0
- package/package.json +7 -7
- package/src/SchemaRenderer.tsx +122 -13
- package/src/__tests__/SchemaRenderer.aria.test.tsx +118 -0
- package/src/__tests__/SchemaRenderer.errorBoundary.test.tsx +114 -0
- package/src/__tests__/SchemaRendererProvider.smoke.test.tsx +132 -0
- package/src/__tests__/useOffline.test.ts +198 -0
- package/src/__tests__/usePageTransition.test.ts +214 -0
- package/src/__tests__/usePerformance.test.ts +137 -0
- package/src/__tests__/view-switching.test.tsx +456 -0
- package/src/context/DndContext.tsx +260 -0
- package/src/context/NotificationContext.tsx +280 -0
- package/src/context/__tests__/DndContext.test.tsx +150 -0
- package/src/context/__tests__/NotificationContext.test.tsx +176 -0
- package/src/context/index.ts +2 -0
- package/src/hooks/__tests__/useAnimation.test.ts +82 -0
- package/src/hooks/__tests__/useColumnSummary.test.ts +93 -0
- package/src/hooks/__tests__/useDensityMode.test.ts +81 -0
- package/src/hooks/__tests__/useETagCache.test.ts +153 -0
- package/src/hooks/__tests__/useFocusTrap.test.ts +47 -0
- package/src/hooks/__tests__/useKeyboardShortcuts.test.ts +144 -0
- package/src/hooks/__tests__/usePerformanceBudget.test.ts +157 -0
- package/src/hooks/__tests__/useSchemaPersistence.test.ts +220 -0
- package/src/hooks/__tests__/useViewSharing.test.ts +121 -0
- package/src/hooks/__tests__/useViewTransition.test.ts +61 -0
- package/src/hooks/index.ts +17 -1
- package/src/hooks/useAnimation.ts +117 -0
- package/src/hooks/useClientNotifications.ts +251 -0
- package/src/hooks/useColumnSummary.ts +87 -0
- package/src/hooks/useCrudShortcuts.ts +126 -0
- package/src/hooks/useDensityMode.ts +102 -0
- package/src/hooks/useETagCache.ts +404 -0
- package/src/hooks/useFocusManagement.ts +248 -0
- package/src/hooks/useFocusTrap.ts +138 -0
- package/src/hooks/useKeyboardShortcuts.ts +119 -0
- package/src/hooks/useOffline.ts +301 -0
- package/src/hooks/usePageTransition.ts +187 -0
- package/src/hooks/usePerformance.ts +246 -0
- package/src/hooks/usePerformanceBudget.ts +313 -0
- package/src/hooks/useReducedMotion.ts +53 -0
- package/src/hooks/useSchemaPersistence.ts +222 -0
- package/src/hooks/useViewSharing.ts +140 -0
- package/src/hooks/useViewTransition.ts +144 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @object-ui/react - Notification Context
|
|
11
|
+
*
|
|
12
|
+
* Provides a spec-driven notification system to the component tree.
|
|
13
|
+
* Implements NotificationSchema, NotificationConfigSchema, and
|
|
14
|
+
* NotificationActionSchema from @objectstack/spec v2.0.7.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
|
18
|
+
|
|
19
|
+
/** Notification severity levels aligned with NotificationSeveritySchema */
|
|
20
|
+
export type NotificationSeverityLevel = 'info' | 'success' | 'warning' | 'error';
|
|
21
|
+
|
|
22
|
+
/** Notification display type aligned with NotificationTypeSchema */
|
|
23
|
+
export type NotificationDisplayType = 'toast' | 'banner' | 'snackbar' | 'modal';
|
|
24
|
+
|
|
25
|
+
/** Notification position aligned with NotificationPositionSchema */
|
|
26
|
+
export type NotificationPositionValue =
|
|
27
|
+
| 'top-left'
|
|
28
|
+
| 'top-center'
|
|
29
|
+
| 'top-right'
|
|
30
|
+
| 'bottom-left'
|
|
31
|
+
| 'bottom-center'
|
|
32
|
+
| 'bottom-right';
|
|
33
|
+
|
|
34
|
+
/** Action button on a notification */
|
|
35
|
+
export interface NotificationActionButton {
|
|
36
|
+
label: string;
|
|
37
|
+
onClick: () => void;
|
|
38
|
+
variant?: 'default' | 'destructive' | 'outline';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A single notification item */
|
|
42
|
+
export interface NotificationItem {
|
|
43
|
+
id: string;
|
|
44
|
+
title: string;
|
|
45
|
+
message?: string;
|
|
46
|
+
severity: NotificationSeverityLevel;
|
|
47
|
+
displayType?: NotificationDisplayType;
|
|
48
|
+
actions?: NotificationActionButton[];
|
|
49
|
+
/** Duration in ms (0 = persistent). Default: 5000 */
|
|
50
|
+
duration?: number;
|
|
51
|
+
/** Whether the notification has been read */
|
|
52
|
+
read?: boolean;
|
|
53
|
+
/** Timestamp */
|
|
54
|
+
createdAt: Date;
|
|
55
|
+
/** Optional icon name (lucide icon) */
|
|
56
|
+
icon?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Configuration for the notification system */
|
|
60
|
+
export interface NotificationSystemConfig {
|
|
61
|
+
/** Default position for toast notifications */
|
|
62
|
+
position?: NotificationPositionValue;
|
|
63
|
+
/** Default duration in ms */
|
|
64
|
+
defaultDuration?: number;
|
|
65
|
+
/** Maximum number of visible notifications */
|
|
66
|
+
maxVisible?: number;
|
|
67
|
+
/** Whether to stack notifications */
|
|
68
|
+
stacking?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface NotificationProviderProps {
|
|
72
|
+
children: React.ReactNode;
|
|
73
|
+
/** System configuration */
|
|
74
|
+
config?: NotificationSystemConfig;
|
|
75
|
+
/** External toast handler (e.g., Sonner) for rendering toasts */
|
|
76
|
+
onToast?: (notification: NotificationItem) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface NotificationContextValue {
|
|
80
|
+
/** All notifications (including history) */
|
|
81
|
+
notifications: NotificationItem[];
|
|
82
|
+
/** Unread notification count */
|
|
83
|
+
unreadCount: number;
|
|
84
|
+
/** Add a notification */
|
|
85
|
+
notify: (notification: Omit<NotificationItem, 'id' | 'createdAt' | 'read'>) => string;
|
|
86
|
+
/** Convenience: show info notification */
|
|
87
|
+
info: (title: string, message?: string) => string;
|
|
88
|
+
/** Convenience: show success notification */
|
|
89
|
+
success: (title: string, message?: string) => string;
|
|
90
|
+
/** Convenience: show warning notification */
|
|
91
|
+
warning: (title: string, message?: string) => string;
|
|
92
|
+
/** Convenience: show error notification */
|
|
93
|
+
error: (title: string, message?: string) => string;
|
|
94
|
+
/** Mark a notification as read */
|
|
95
|
+
markAsRead: (id: string) => void;
|
|
96
|
+
/** Mark all notifications as read */
|
|
97
|
+
markAllAsRead: () => void;
|
|
98
|
+
/** Dismiss a notification */
|
|
99
|
+
dismiss: (id: string) => void;
|
|
100
|
+
/** Clear all notifications */
|
|
101
|
+
clearAll: () => void;
|
|
102
|
+
/** System configuration */
|
|
103
|
+
config: NotificationSystemConfig;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let notificationCounter = 0;
|
|
107
|
+
|
|
108
|
+
const NotificationCtx = createContext<NotificationContextValue | null>(null);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* NotificationProvider — Provides a spec-driven notification system.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```tsx
|
|
115
|
+
* <NotificationProvider
|
|
116
|
+
* config={{ position: 'top-right', defaultDuration: 5000, maxVisible: 5 }}
|
|
117
|
+
* onToast={(n) => toast[n.severity](n.title, { description: n.message })}
|
|
118
|
+
* >
|
|
119
|
+
* <App />
|
|
120
|
+
* </NotificationProvider>
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
export const NotificationProvider: React.FC<NotificationProviderProps> = ({
|
|
124
|
+
children,
|
|
125
|
+
config: userConfig = {},
|
|
126
|
+
onToast,
|
|
127
|
+
}) => {
|
|
128
|
+
const config = useMemo<NotificationSystemConfig>(
|
|
129
|
+
() => ({
|
|
130
|
+
position: 'top-right',
|
|
131
|
+
defaultDuration: 5000,
|
|
132
|
+
maxVisible: 5,
|
|
133
|
+
stacking: true,
|
|
134
|
+
...userConfig,
|
|
135
|
+
}),
|
|
136
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
137
|
+
[JSON.stringify(userConfig)],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
|
141
|
+
|
|
142
|
+
const notify = useCallback(
|
|
143
|
+
(input: Omit<NotificationItem, 'id' | 'createdAt' | 'read'>): string => {
|
|
144
|
+
const id = `notification-${++notificationCounter}`;
|
|
145
|
+
const notification: NotificationItem = {
|
|
146
|
+
...input,
|
|
147
|
+
id,
|
|
148
|
+
createdAt: new Date(),
|
|
149
|
+
read: false,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
setNotifications((prev) => [notification, ...prev]);
|
|
153
|
+
|
|
154
|
+
// Delegate to external toast handler if provided
|
|
155
|
+
if (onToast) {
|
|
156
|
+
onToast(notification);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Auto-dismiss non-persistent notifications
|
|
160
|
+
const duration = input.duration ?? config.defaultDuration ?? 5000;
|
|
161
|
+
if (duration > 0) {
|
|
162
|
+
setTimeout(() => {
|
|
163
|
+
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
|
164
|
+
}, duration);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return id;
|
|
168
|
+
},
|
|
169
|
+
[config.defaultDuration, onToast],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const info = useCallback(
|
|
173
|
+
(title: string, message?: string) =>
|
|
174
|
+
notify({ title, message, severity: 'info' }),
|
|
175
|
+
[notify],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const success = useCallback(
|
|
179
|
+
(title: string, message?: string) =>
|
|
180
|
+
notify({ title, message, severity: 'success' }),
|
|
181
|
+
[notify],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const warning = useCallback(
|
|
185
|
+
(title: string, message?: string) =>
|
|
186
|
+
notify({ title, message, severity: 'warning' }),
|
|
187
|
+
[notify],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const error = useCallback(
|
|
191
|
+
(title: string, message?: string) =>
|
|
192
|
+
notify({ title, message, severity: 'error' }),
|
|
193
|
+
[notify],
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const markAsRead = useCallback((id: string) => {
|
|
197
|
+
setNotifications((prev) =>
|
|
198
|
+
prev.map((n) => (n.id === id ? { ...n, read: true } : n)),
|
|
199
|
+
);
|
|
200
|
+
}, []);
|
|
201
|
+
|
|
202
|
+
const markAllAsRead = useCallback(() => {
|
|
203
|
+
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
|
204
|
+
}, []);
|
|
205
|
+
|
|
206
|
+
const dismiss = useCallback((id: string) => {
|
|
207
|
+
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
|
208
|
+
}, []);
|
|
209
|
+
|
|
210
|
+
const clearAll = useCallback(() => {
|
|
211
|
+
setNotifications([]);
|
|
212
|
+
}, []);
|
|
213
|
+
|
|
214
|
+
const unreadCount = useMemo(
|
|
215
|
+
() => notifications.filter((n) => !n.read).length,
|
|
216
|
+
[notifications],
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const value = useMemo<NotificationContextValue>(
|
|
220
|
+
() => ({
|
|
221
|
+
notifications,
|
|
222
|
+
unreadCount,
|
|
223
|
+
notify,
|
|
224
|
+
info,
|
|
225
|
+
success,
|
|
226
|
+
warning,
|
|
227
|
+
error,
|
|
228
|
+
markAsRead,
|
|
229
|
+
markAllAsRead,
|
|
230
|
+
dismiss,
|
|
231
|
+
clearAll,
|
|
232
|
+
config,
|
|
233
|
+
}),
|
|
234
|
+
[
|
|
235
|
+
notifications,
|
|
236
|
+
unreadCount,
|
|
237
|
+
notify,
|
|
238
|
+
info,
|
|
239
|
+
success,
|
|
240
|
+
warning,
|
|
241
|
+
error,
|
|
242
|
+
markAsRead,
|
|
243
|
+
markAllAsRead,
|
|
244
|
+
dismiss,
|
|
245
|
+
clearAll,
|
|
246
|
+
config,
|
|
247
|
+
],
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<NotificationCtx.Provider value={value}>
|
|
252
|
+
{children}
|
|
253
|
+
</NotificationCtx.Provider>
|
|
254
|
+
);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
NotificationProvider.displayName = 'NotificationProvider';
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Hook to consume the NotificationProvider context.
|
|
261
|
+
*
|
|
262
|
+
* @throws Error if used outside a NotificationProvider
|
|
263
|
+
*/
|
|
264
|
+
export function useNotifications(): NotificationContextValue {
|
|
265
|
+
const ctx = useContext(NotificationCtx);
|
|
266
|
+
if (!ctx) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
'useNotifications must be used within a <NotificationProvider>. ' +
|
|
269
|
+
'Wrap your app with <NotificationProvider> to use the notification system.',
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return ctx;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Hook to check if a NotificationProvider is available.
|
|
277
|
+
*/
|
|
278
|
+
export function useHasNotificationProvider(): boolean {
|
|
279
|
+
return useContext(NotificationCtx) !== null;
|
|
280
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for DndProvider and useDnd
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
5
|
+
import { renderHook, act } from '@testing-library/react';
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import {
|
|
8
|
+
DndProvider,
|
|
9
|
+
useDnd,
|
|
10
|
+
useHasDndProvider,
|
|
11
|
+
} from '../DndContext';
|
|
12
|
+
|
|
13
|
+
const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
14
|
+
<DndProvider>{children}</DndProvider>
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
describe('DndProvider', () => {
|
|
18
|
+
it('provides DnD context', () => {
|
|
19
|
+
const { result } = renderHook(() => useDnd(), { wrapper });
|
|
20
|
+
expect(result.current.enabled).toBe(true);
|
|
21
|
+
expect(result.current.activeItem).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('starts drag operation', () => {
|
|
25
|
+
const onDragStart = vi.fn();
|
|
26
|
+
const customWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
27
|
+
<DndProvider onDragStart={onDragStart}>{children}</DndProvider>
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const { result } = renderHook(() => useDnd(), { wrapper: customWrapper });
|
|
31
|
+
|
|
32
|
+
act(() => {
|
|
33
|
+
result.current.startDrag({ id: 'item-1', type: 'card' });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(result.current.activeItem).toEqual({ id: 'item-1', type: 'card' });
|
|
37
|
+
expect(onDragStart).toHaveBeenCalledTimes(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('ends drag operation', () => {
|
|
41
|
+
const onDrop = vi.fn();
|
|
42
|
+
const customWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
43
|
+
<DndProvider onDrop={onDrop}>{children}</DndProvider>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const { result } = renderHook(() => useDnd(), { wrapper: customWrapper });
|
|
47
|
+
|
|
48
|
+
act(() => {
|
|
49
|
+
result.current.startDrag({ id: 'item-1', type: 'card' });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
act(() => {
|
|
53
|
+
result.current.endDrag('zone-1');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(result.current.activeItem).toBeNull();
|
|
57
|
+
expect(onDrop).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(onDrop).toHaveBeenCalledWith(
|
|
59
|
+
expect.objectContaining({ item: { id: 'item-1', type: 'card' }, targetZoneId: 'zone-1' })
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('cancels drag operation', () => {
|
|
64
|
+
const onDragCancel = vi.fn();
|
|
65
|
+
const customWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
66
|
+
<DndProvider onDragCancel={onDragCancel}>{children}</DndProvider>
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const { result } = renderHook(() => useDnd(), { wrapper: customWrapper });
|
|
70
|
+
|
|
71
|
+
act(() => {
|
|
72
|
+
result.current.startDrag({ id: 'item-1', type: 'card' });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
act(() => {
|
|
76
|
+
result.current.cancelDrag();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(result.current.activeItem).toBeNull();
|
|
80
|
+
expect(onDragCancel).toHaveBeenCalledTimes(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('registers and checks drop zones', () => {
|
|
84
|
+
const { result } = renderHook(() => useDnd(), { wrapper });
|
|
85
|
+
|
|
86
|
+
act(() => {
|
|
87
|
+
result.current.registerDropZone({
|
|
88
|
+
id: 'zone-1',
|
|
89
|
+
acceptTypes: ['card', 'widget'],
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result.current.canDrop('zone-1', 'card')).toBe(true);
|
|
94
|
+
expect(result.current.canDrop('zone-1', 'unknown')).toBe(false);
|
|
95
|
+
expect(result.current.canDrop('nonexistent', 'card')).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('unregisters drop zones', () => {
|
|
99
|
+
const { result } = renderHook(() => useDnd(), { wrapper });
|
|
100
|
+
|
|
101
|
+
act(() => {
|
|
102
|
+
result.current.registerDropZone({
|
|
103
|
+
id: 'zone-1',
|
|
104
|
+
acceptTypes: ['card'],
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
act(() => {
|
|
109
|
+
result.current.unregisterDropZone('zone-1');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(result.current.canDrop('zone-1', 'card')).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('respects disabled config', () => {
|
|
116
|
+
const onDragStart = vi.fn();
|
|
117
|
+
const customWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
118
|
+
<DndProvider config={{ enabled: false }} onDragStart={onDragStart}>{children}</DndProvider>
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const { result } = renderHook(() => useDnd(), { wrapper: customWrapper });
|
|
122
|
+
|
|
123
|
+
act(() => {
|
|
124
|
+
result.current.startDrag({ id: 'item-1', type: 'card' });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(result.current.activeItem).toBeNull();
|
|
128
|
+
expect(onDragStart).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('useHasDndProvider', () => {
|
|
133
|
+
it('returns false outside provider', () => {
|
|
134
|
+
const { result } = renderHook(() => useHasDndProvider());
|
|
135
|
+
expect(result.current).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('returns true inside provider', () => {
|
|
139
|
+
const { result } = renderHook(() => useHasDndProvider(), { wrapper });
|
|
140
|
+
expect(result.current).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('useDnd without provider', () => {
|
|
145
|
+
it('throws error', () => {
|
|
146
|
+
expect(() => {
|
|
147
|
+
renderHook(() => useDnd());
|
|
148
|
+
}).toThrow('useDnd must be used within a <DndProvider>');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for NotificationProvider and useNotifications
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
5
|
+
import { renderHook, act } from '@testing-library/react';
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import {
|
|
8
|
+
NotificationProvider,
|
|
9
|
+
useNotifications,
|
|
10
|
+
useHasNotificationProvider,
|
|
11
|
+
} from '../NotificationContext';
|
|
12
|
+
|
|
13
|
+
const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
14
|
+
<NotificationProvider>{children}</NotificationProvider>
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
describe('NotificationProvider', () => {
|
|
18
|
+
it('provides notifications context', () => {
|
|
19
|
+
const { result } = renderHook(() => useNotifications(), { wrapper });
|
|
20
|
+
expect(result.current.notifications).toEqual([]);
|
|
21
|
+
expect(result.current.unreadCount).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('adds a notification via notify', () => {
|
|
25
|
+
const { result } = renderHook(() => useNotifications(), { wrapper });
|
|
26
|
+
|
|
27
|
+
act(() => {
|
|
28
|
+
result.current.notify({ title: 'Hello', severity: 'info' });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(result.current.notifications).toHaveLength(1);
|
|
32
|
+
expect(result.current.notifications[0].title).toBe('Hello');
|
|
33
|
+
expect(result.current.unreadCount).toBe(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('adds info notification', () => {
|
|
37
|
+
const { result } = renderHook(() => useNotifications(), { wrapper });
|
|
38
|
+
|
|
39
|
+
act(() => {
|
|
40
|
+
result.current.info('Info title', 'Info message');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(result.current.notifications[0].severity).toBe('info');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('adds success notification', () => {
|
|
47
|
+
const { result } = renderHook(() => useNotifications(), { wrapper });
|
|
48
|
+
|
|
49
|
+
act(() => {
|
|
50
|
+
result.current.success('Success!');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(result.current.notifications[0].severity).toBe('success');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('adds warning notification', () => {
|
|
57
|
+
const { result } = renderHook(() => useNotifications(), { wrapper });
|
|
58
|
+
|
|
59
|
+
act(() => {
|
|
60
|
+
result.current.warning('Warning!');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(result.current.notifications[0].severity).toBe('warning');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('adds error notification', () => {
|
|
67
|
+
const { result } = renderHook(() => useNotifications(), { wrapper });
|
|
68
|
+
|
|
69
|
+
act(() => {
|
|
70
|
+
result.current.error('Error!');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result.current.notifications[0].severity).toBe('error');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('marks notification as read', () => {
|
|
77
|
+
const { result } = renderHook(() => useNotifications(), { wrapper });
|
|
78
|
+
|
|
79
|
+
let id: string = '';
|
|
80
|
+
act(() => {
|
|
81
|
+
id = result.current.info('Test');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
act(() => {
|
|
85
|
+
result.current.markAsRead(id);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(result.current.notifications[0].read).toBe(true);
|
|
89
|
+
expect(result.current.unreadCount).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('marks all as read', () => {
|
|
93
|
+
const { result } = renderHook(() => useNotifications(), { wrapper });
|
|
94
|
+
|
|
95
|
+
act(() => {
|
|
96
|
+
result.current.info('Test 1');
|
|
97
|
+
result.current.info('Test 2');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
act(() => {
|
|
101
|
+
result.current.markAllAsRead();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(result.current.unreadCount).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('dismisses a notification', () => {
|
|
108
|
+
const { result } = renderHook(() => useNotifications(), { wrapper });
|
|
109
|
+
|
|
110
|
+
let id: string = '';
|
|
111
|
+
act(() => {
|
|
112
|
+
id = result.current.info('Test');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
act(() => {
|
|
116
|
+
result.current.dismiss(id);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(result.current.notifications).toHaveLength(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('clears all notifications', () => {
|
|
123
|
+
const { result } = renderHook(() => useNotifications(), { wrapper });
|
|
124
|
+
|
|
125
|
+
act(() => {
|
|
126
|
+
result.current.info('Test 1');
|
|
127
|
+
result.current.info('Test 2');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
act(() => {
|
|
131
|
+
result.current.clearAll();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(result.current.notifications).toHaveLength(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('calls onToast handler', () => {
|
|
138
|
+
const onToast = vi.fn();
|
|
139
|
+
const customWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
140
|
+
<NotificationProvider onToast={onToast}>{children}</NotificationProvider>
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const { result } = renderHook(() => useNotifications(), {
|
|
144
|
+
wrapper: customWrapper,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
act(() => {
|
|
148
|
+
result.current.info('Toast test');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(onToast).toHaveBeenCalledTimes(1);
|
|
152
|
+
expect(onToast).toHaveBeenCalledWith(
|
|
153
|
+
expect.objectContaining({ title: 'Toast test', severity: 'info' })
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('useHasNotificationProvider', () => {
|
|
159
|
+
it('returns false outside provider', () => {
|
|
160
|
+
const { result } = renderHook(() => useHasNotificationProvider());
|
|
161
|
+
expect(result.current).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('returns true inside provider', () => {
|
|
165
|
+
const { result } = renderHook(() => useHasNotificationProvider(), { wrapper });
|
|
166
|
+
expect(result.current).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('useNotifications without provider', () => {
|
|
171
|
+
it('throws error', () => {
|
|
172
|
+
expect(() => {
|
|
173
|
+
renderHook(() => useNotifications());
|
|
174
|
+
}).toThrow('useNotifications must be used within a <NotificationProvider>');
|
|
175
|
+
});
|
|
176
|
+
});
|
package/src/context/index.ts
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useAnimation and useReducedMotion hooks
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { renderHook } from '@testing-library/react';
|
|
6
|
+
import { useAnimation } from '../useAnimation';
|
|
7
|
+
import { useReducedMotion } from '../useReducedMotion';
|
|
8
|
+
|
|
9
|
+
describe('useAnimation', () => {
|
|
10
|
+
it('returns empty className and style for no config', () => {
|
|
11
|
+
const { result } = renderHook(() => useAnimation());
|
|
12
|
+
expect(result.current.className).toBe('');
|
|
13
|
+
expect(result.current.style).toEqual({});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns empty when preset is none', () => {
|
|
17
|
+
const { result } = renderHook(() => useAnimation({ preset: 'none' }));
|
|
18
|
+
expect(result.current.className).toBe('');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns empty when disabled', () => {
|
|
22
|
+
const { result } = renderHook(() =>
|
|
23
|
+
useAnimation({ preset: 'fade', enabled: false })
|
|
24
|
+
);
|
|
25
|
+
expect(result.current.className).toBe('');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns correct className for fade preset', () => {
|
|
29
|
+
const { result } = renderHook(() => useAnimation({ preset: 'fade' }));
|
|
30
|
+
expect(result.current.className).toContain('fade-in');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns correct className for slide-up preset', () => {
|
|
34
|
+
const { result } = renderHook(() => useAnimation({ preset: 'slide-up' }));
|
|
35
|
+
expect(result.current.className).toContain('slide-in-from-bottom');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns correct className for scale preset', () => {
|
|
39
|
+
const { result } = renderHook(() => useAnimation({ preset: 'scale' }));
|
|
40
|
+
expect(result.current.className).toContain('zoom-in');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns correct className for scale-fade preset', () => {
|
|
44
|
+
const { result } = renderHook(() => useAnimation({ preset: 'scale-fade' }));
|
|
45
|
+
expect(result.current.className).toContain('fade-in');
|
|
46
|
+
expect(result.current.className).toContain('zoom-in');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('applies custom duration', () => {
|
|
50
|
+
const { result } = renderHook(() =>
|
|
51
|
+
useAnimation({ preset: 'fade', duration: 300 })
|
|
52
|
+
);
|
|
53
|
+
expect(result.current.style.animationDuration).toBe('300ms');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('applies custom delay', () => {
|
|
57
|
+
const { result } = renderHook(() =>
|
|
58
|
+
useAnimation({ preset: 'fade', delay: 100 })
|
|
59
|
+
);
|
|
60
|
+
expect(result.current.style.animationDelay).toBe('100ms');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('applies easing function', () => {
|
|
64
|
+
const { result } = renderHook(() =>
|
|
65
|
+
useAnimation({ preset: 'fade', easing: 'spring' })
|
|
66
|
+
);
|
|
67
|
+
expect(result.current.style.animationTimingFunction).toContain('cubic-bezier');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('useReducedMotion', () => {
|
|
72
|
+
it('returns a boolean value', () => {
|
|
73
|
+
const { result } = renderHook(() => useReducedMotion());
|
|
74
|
+
expect(typeof result.current).toBe('boolean');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('defaults to false when matchMedia returns false', () => {
|
|
78
|
+
const { result } = renderHook(() => useReducedMotion());
|
|
79
|
+
// jsdom matchMedia always returns false by default
|
|
80
|
+
expect(result.current).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|