@djangocfg/ui-nextjs 2.1.4 → 2.1.6
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/package.json +13 -12
- package/src/components/markdown/MarkdownMessage.tsx +11 -26
- package/src/components/sidebar.tsx +3 -1
- package/src/components/ssr-pagination.tsx +14 -30
- package/src/hooks/index.ts +7 -2
- package/src/hooks/useHotkey.ts +103 -0
- package/src/hooks/{useTheme.ts → useResolvedTheme.ts} +29 -18
- package/src/theme/ThemeProvider.tsx +55 -61
- package/src/theme/ThemeToggle.tsx +15 -11
- package/src/theme/index.ts +2 -1
- package/src/tools/JsonForm/JsonSchemaForm.tsx +7 -0
- package/src/tools/JsonForm/templates/ObjectFieldTemplate.tsx +90 -14
- package/src/tools/JsonForm/widgets/ColorWidget.tsx +218 -0
- package/src/tools/JsonForm/widgets/SliderWidget.tsx +147 -0
- package/src/tools/JsonForm/widgets/index.ts +2 -0
- package/src/tools/JsonTree/index.tsx +10 -20
- package/src/tools/Mermaid/Mermaid.client.tsx +2 -2
- package/src/tools/OpenapiViewer/components/EndpointInfo.tsx +20 -21
- package/src/tools/OpenapiViewer/components/RequestBuilder.tsx +7 -17
- package/src/tools/OpenapiViewer/components/ResponseViewer.tsx +26 -26
- package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +0 -7
- package/src/tools/OpenapiViewer/types.ts +0 -1
- package/src/tools/PrettyCode/PrettyCode.client.tsx +10 -25
- package/src/tools/VideoPlayer/VideoPlayer.tsx +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-nextjs",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.6",
|
|
4
4
|
"description": "Next.js UI component library with Radix UI primitives, Tailwind CSS styling, charts, and form components",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -58,10 +58,11 @@
|
|
|
58
58
|
"check": "tsc --noEmit"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"@djangocfg/api": "^2.1.
|
|
62
|
-
"@djangocfg/ui-core": "^1.
|
|
61
|
+
"@djangocfg/api": "^2.1.6",
|
|
62
|
+
"@djangocfg/ui-core": "^2.1.6",
|
|
63
63
|
"@types/react": "^19.1.0",
|
|
64
64
|
"@types/react-dom": "^19.1.0",
|
|
65
|
+
"consola": "^3.4.2",
|
|
65
66
|
"lucide-react": "^0.545.0",
|
|
66
67
|
"moment": "^2.30.1",
|
|
67
68
|
"next": "^15.5.7",
|
|
@@ -69,13 +70,9 @@
|
|
|
69
70
|
"react-dom": "^19.1.0",
|
|
70
71
|
"react-hook-form": "7.65.0",
|
|
71
72
|
"tailwindcss": "^4.1.14",
|
|
72
|
-
"zod": "^4.1.13"
|
|
73
|
-
"consola": "^3.4.2"
|
|
73
|
+
"zod": "^4.1.13"
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
|
-
"class-variance-authority": "^0.7.1",
|
|
77
|
-
"libphonenumber-js": "^1.12.24",
|
|
78
|
-
"sonner": "2.0.7",
|
|
79
76
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
80
77
|
"@radix-ui/react-menubar": "^1.1.16",
|
|
81
78
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
|
@@ -83,26 +80,30 @@
|
|
|
83
80
|
"@rjsf/core": "^6.1.2",
|
|
84
81
|
"@rjsf/utils": "^6.1.2",
|
|
85
82
|
"@rjsf/validator-ajv8": "^6.1.2",
|
|
83
|
+
"@vidstack/react": "next",
|
|
86
84
|
"@web3icons/react": "^4.0.26",
|
|
87
85
|
"chart.js": "^4.5.0",
|
|
86
|
+
"class-variance-authority": "^0.7.1",
|
|
88
87
|
"cytoscape": "^3.33.1",
|
|
89
88
|
"cytoscape-cose-bilkent": "^4.1.0",
|
|
89
|
+
"libphonenumber-js": "^1.12.24",
|
|
90
|
+
"media-icons": "next",
|
|
90
91
|
"mermaid": "^11.12.0",
|
|
91
92
|
"next-themes": "^0.4.6",
|
|
92
93
|
"prism-react-renderer": "^2.4.1",
|
|
93
94
|
"react-chartjs-2": "^5.3.0",
|
|
95
|
+
"react-hotkeys-hook": "^5.2.1",
|
|
94
96
|
"react-json-tree": "^0.20.0",
|
|
95
97
|
"react-lottie-player": "^2.1.0",
|
|
96
98
|
"react-markdown": "10.1.0",
|
|
97
|
-
"remark-gfm": "4.0.1",
|
|
98
99
|
"react-sticky-box": "^2.0.5",
|
|
99
100
|
"recharts": "2.15.4",
|
|
100
|
-
"
|
|
101
|
-
"
|
|
101
|
+
"remark-gfm": "4.0.1",
|
|
102
|
+
"sonner": "2.0.7",
|
|
102
103
|
"vidstack": "next"
|
|
103
104
|
},
|
|
104
105
|
"devDependencies": {
|
|
105
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
106
|
+
"@djangocfg/typescript-config": "^2.1.6",
|
|
106
107
|
"@types/node": "^24.7.2",
|
|
107
108
|
"eslint": "^9.37.0",
|
|
108
109
|
"tailwindcss-animate": "1.0.7",
|
|
@@ -5,10 +5,8 @@ import ReactMarkdown from 'react-markdown';
|
|
|
5
5
|
import remarkGfm from 'remark-gfm';
|
|
6
6
|
import Mermaid from '../../tools/Mermaid';
|
|
7
7
|
import PrettyCode from '../../tools/PrettyCode';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { useTheme } from '../../hooks/useTheme';
|
|
11
|
-
import { Copy } from 'lucide-react';
|
|
8
|
+
import { CopyButton } from '@djangocfg/ui-core/components';
|
|
9
|
+
import { useResolvedTheme } from '../../hooks/useResolvedTheme';
|
|
12
10
|
import type { Components } from 'react-markdown';
|
|
13
11
|
|
|
14
12
|
// Helper function to extract text content from React children
|
|
@@ -52,32 +50,24 @@ interface CodeBlockProps {
|
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
const CodeBlock: React.FC<CodeBlockProps> = ({ code, language, isUser }) => {
|
|
55
|
-
const
|
|
56
|
-
const theme = useTheme();
|
|
57
|
-
|
|
58
|
-
const handleCopy = () => {
|
|
59
|
-
copyToClipboard(code, "Code copied to clipboard!");
|
|
60
|
-
};
|
|
53
|
+
const theme = useResolvedTheme();
|
|
61
54
|
|
|
62
55
|
return (
|
|
63
56
|
<div className="relative group my-3">
|
|
64
57
|
{/* Copy button */}
|
|
65
|
-
<
|
|
58
|
+
<CopyButton
|
|
59
|
+
value={code}
|
|
66
60
|
variant="ghost"
|
|
67
|
-
size="sm"
|
|
68
|
-
onClick={handleCopy}
|
|
69
61
|
className={`
|
|
70
62
|
absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity
|
|
71
|
-
h-8 w-8
|
|
63
|
+
h-8 w-8
|
|
72
64
|
${isUser
|
|
73
65
|
? 'hover:bg-white/20 text-white'
|
|
74
66
|
: 'hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground'
|
|
75
67
|
}
|
|
76
68
|
`}
|
|
77
69
|
title="Copy code"
|
|
78
|
-
|
|
79
|
-
<Copy className="h-4 w-4" />
|
|
80
|
-
</Button>
|
|
70
|
+
/>
|
|
81
71
|
|
|
82
72
|
{/* Code content */}
|
|
83
73
|
<PrettyCode
|
|
@@ -195,24 +185,19 @@ const createMarkdownComponents = (isUser: boolean = false, isCompact: boolean =
|
|
|
195
185
|
console.warn('CodeBlock failed, using fallback:', error);
|
|
196
186
|
return (
|
|
197
187
|
<div className="relative group my-3">
|
|
198
|
-
<
|
|
188
|
+
<CopyButton
|
|
189
|
+
value={codeContent}
|
|
199
190
|
variant="ghost"
|
|
200
|
-
size="sm"
|
|
201
|
-
onClick={() => {
|
|
202
|
-
navigator.clipboard.writeText(codeContent);
|
|
203
|
-
}}
|
|
204
191
|
className={`
|
|
205
192
|
absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity
|
|
206
|
-
h-8 w-8
|
|
193
|
+
h-8 w-8
|
|
207
194
|
${isUser
|
|
208
195
|
? 'hover:bg-white/20 text-white'
|
|
209
196
|
: 'hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground'
|
|
210
197
|
}
|
|
211
198
|
`}
|
|
212
199
|
title="Copy code"
|
|
213
|
-
|
|
214
|
-
<Copy className="h-4 w-4" />
|
|
215
|
-
</Button>
|
|
200
|
+
/>
|
|
216
201
|
<pre className={`
|
|
217
202
|
p-3 rounded text-xs font-mono overflow-x-auto
|
|
218
203
|
${isUser
|
|
@@ -440,7 +440,9 @@ const SidebarFooter = React.forwardRef<
|
|
|
440
440
|
})
|
|
441
441
|
SidebarFooter.displayName = "SidebarFooter"
|
|
442
442
|
|
|
443
|
-
const SidebarSeparator
|
|
443
|
+
const SidebarSeparator: React.ForwardRefExoticComponent<
|
|
444
|
+
React.ComponentProps<typeof Separator> & React.RefAttributes<React.ElementRef<typeof Separator>>
|
|
445
|
+
> = React.forwardRef<
|
|
444
446
|
React.ElementRef<typeof Separator>,
|
|
445
447
|
React.ComponentProps<typeof Separator>
|
|
446
448
|
>(({ className, ...props }, ref) => {
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import { usePathname } from 'next/navigation';
|
|
5
|
-
import Link from 'next/link';
|
|
6
5
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
6
|
import { useIsMobile } from '@djangocfg/ui-core/hooks';
|
|
8
7
|
import { useQueryParams } from '../hooks';
|
|
@@ -29,7 +28,6 @@ interface SSRPaginationProps {
|
|
|
29
28
|
baseUrl?: string;
|
|
30
29
|
pathname?: string;
|
|
31
30
|
preserveQuery?: boolean;
|
|
32
|
-
prefetch?: boolean;
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
export const SSRPagination: React.FC<SSRPaginationProps> = ({
|
|
@@ -45,7 +43,6 @@ export const SSRPagination: React.FC<SSRPaginationProps> = ({
|
|
|
45
43
|
baseUrl,
|
|
46
44
|
pathname: propPathname,
|
|
47
45
|
preserveQuery = true,
|
|
48
|
-
prefetch = true,
|
|
49
46
|
}) => {
|
|
50
47
|
const queryParams = useQueryParams();
|
|
51
48
|
const pathname = usePathname();
|
|
@@ -186,17 +183,10 @@ export const SSRPagination: React.FC<SSRPaginationProps> = ({
|
|
|
186
183
|
<PaginationContent>
|
|
187
184
|
{/* Previous Button */}
|
|
188
185
|
<PaginationItem>
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
) : (
|
|
194
|
-
<PaginationPrevious
|
|
195
|
-
href="#"
|
|
196
|
-
className="pointer-events-none opacity-50"
|
|
197
|
-
onClick={(e) => e.preventDefault()}
|
|
198
|
-
/>
|
|
199
|
-
)}
|
|
186
|
+
<PaginationPrevious
|
|
187
|
+
href={actualHasPreviousPage ? getPageUrl(actualCurrentPage - 1) : undefined}
|
|
188
|
+
className={!actualHasPreviousPage ? "pointer-events-none opacity-50" : undefined}
|
|
189
|
+
/>
|
|
200
190
|
</PaginationItem>
|
|
201
191
|
|
|
202
192
|
{/* Page Numbers */}
|
|
@@ -205,28 +195,22 @@ export const SSRPagination: React.FC<SSRPaginationProps> = ({
|
|
|
205
195
|
{page === 'ellipsis' ? (
|
|
206
196
|
<PaginationEllipsis />
|
|
207
197
|
) : (
|
|
208
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
198
|
+
<PaginationLink
|
|
199
|
+
href={getPageUrl(page)}
|
|
200
|
+
isActive={page === actualCurrentPage}
|
|
201
|
+
>
|
|
202
|
+
{page}
|
|
203
|
+
</PaginationLink>
|
|
213
204
|
)}
|
|
214
205
|
</PaginationItem>
|
|
215
206
|
))}
|
|
216
207
|
|
|
217
208
|
{/* Next Button */}
|
|
218
209
|
<PaginationItem>
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
) : (
|
|
224
|
-
<PaginationNext
|
|
225
|
-
href="#"
|
|
226
|
-
className="pointer-events-none opacity-50"
|
|
227
|
-
onClick={(e) => e.preventDefault()}
|
|
228
|
-
/>
|
|
229
|
-
)}
|
|
210
|
+
<PaginationNext
|
|
211
|
+
href={hasNextPage ? getPageUrl(actualCurrentPage + 1) : undefined}
|
|
212
|
+
className={!hasNextPage ? "pointer-events-none opacity-50" : undefined}
|
|
213
|
+
/>
|
|
230
214
|
</PaginationItem>
|
|
231
215
|
</PaginationContent>
|
|
232
216
|
</Pagination>
|
package/src/hooks/index.ts
CHANGED
|
@@ -11,9 +11,14 @@ export * from '@djangocfg/ui-core/hooks';
|
|
|
11
11
|
export { useLocalStorage } from './useLocalStorage';
|
|
12
12
|
export { useSessionStorage } from './useSessionStorage';
|
|
13
13
|
|
|
14
|
-
// Theme hook (
|
|
15
|
-
export {
|
|
14
|
+
// Theme hook (standalone, no provider required)
|
|
15
|
+
export { useResolvedTheme } from './useResolvedTheme';
|
|
16
|
+
export type { ResolvedTheme } from './useResolvedTheme';
|
|
16
17
|
|
|
17
18
|
// Next.js router hooks
|
|
18
19
|
export { useQueryParams } from './useQueryParams';
|
|
19
20
|
export { useCfgRouter } from './useCfgRouter';
|
|
21
|
+
|
|
22
|
+
// Keyboard shortcuts
|
|
23
|
+
export { useHotkey, useHotkeysContext, HotkeysProvider, isHotkeyPressed } from './useHotkey';
|
|
24
|
+
export type { UseHotkeyOptions, HotkeyCallback, Keys } from './useHotkey';
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { RefObject } from 'react';
|
|
4
|
+
import { useHotkeys, Options as HotkeysOptions } from 'react-hotkeys-hook';
|
|
5
|
+
import type { HotkeyCallback, Keys } from 'react-hotkeys-hook';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Options for the useHotkey hook
|
|
9
|
+
*/
|
|
10
|
+
export interface UseHotkeyOptions extends Omit<HotkeysOptions, 'enabled'> {
|
|
11
|
+
/** Whether the hotkey is enabled (default: true) */
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
/** Scope for the hotkey - useful for context-specific shortcuts */
|
|
14
|
+
scope?: string;
|
|
15
|
+
/** Only trigger when focus is within a specific element */
|
|
16
|
+
scopes?: string[];
|
|
17
|
+
/** Prevent default browser behavior */
|
|
18
|
+
preventDefault?: boolean;
|
|
19
|
+
/** Enable in input fields and textareas */
|
|
20
|
+
enableOnFormTags?: boolean | readonly ('input' | 'textarea' | 'select')[];
|
|
21
|
+
/** Enable when contentEditable element is focused */
|
|
22
|
+
enableOnContentEditable?: boolean;
|
|
23
|
+
/** Split key for multiple hotkey combinations (default: ',') */
|
|
24
|
+
splitKey?: string;
|
|
25
|
+
/** Key up/down events */
|
|
26
|
+
keyup?: boolean;
|
|
27
|
+
keydown?: boolean;
|
|
28
|
+
/** Description for the hotkey (useful for help dialogs) */
|
|
29
|
+
description?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Simple wrapper hook for react-hotkeys-hook
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* // Single key
|
|
37
|
+
* useHotkey('escape', () => closeModal());
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* // Key combination
|
|
41
|
+
* useHotkey('ctrl+s', (e) => {
|
|
42
|
+
* e.preventDefault();
|
|
43
|
+
* saveDocument();
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* // Multiple keys (any of them will trigger)
|
|
48
|
+
* useHotkey(['ArrowLeft', '['], () => goToPrevious());
|
|
49
|
+
* useHotkey(['ArrowRight', ']'], () => goToNext());
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* // With options
|
|
53
|
+
* useHotkey('/', () => focusSearch(), {
|
|
54
|
+
* preventDefault: true,
|
|
55
|
+
* enableOnFormTags: false,
|
|
56
|
+
* description: 'Focus search input'
|
|
57
|
+
* });
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* // Scoped hotkeys
|
|
61
|
+
* useHotkey('delete', () => deleteItem(), { scopes: ['list-view'] });
|
|
62
|
+
*
|
|
63
|
+
* @param keys - Hotkey or array of hotkeys (e.g., 'ctrl+s', 'ArrowLeft', ['[', 'ArrowLeft'])
|
|
64
|
+
* @param callback - Function to call when hotkey is pressed
|
|
65
|
+
* @param options - Configuration options
|
|
66
|
+
* @returns Ref to attach to element for scoped hotkeys
|
|
67
|
+
*/
|
|
68
|
+
export function useHotkey<T extends HTMLElement = HTMLElement>(
|
|
69
|
+
keys: Keys,
|
|
70
|
+
callback: HotkeyCallback,
|
|
71
|
+
options: UseHotkeyOptions = {}
|
|
72
|
+
): RefObject<T | null> {
|
|
73
|
+
const {
|
|
74
|
+
enabled = true,
|
|
75
|
+
preventDefault = false,
|
|
76
|
+
enableOnFormTags = false,
|
|
77
|
+
enableOnContentEditable = false,
|
|
78
|
+
description: _description,
|
|
79
|
+
...restOptions
|
|
80
|
+
} = options;
|
|
81
|
+
|
|
82
|
+
return useHotkeys<T>(
|
|
83
|
+
keys,
|
|
84
|
+
(event, handler) => {
|
|
85
|
+
if (preventDefault) {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
}
|
|
88
|
+
callback(event, handler);
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
enabled,
|
|
92
|
+
enableOnFormTags,
|
|
93
|
+
enableOnContentEditable,
|
|
94
|
+
...restOptions,
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Re-export useful utilities from react-hotkeys-hook
|
|
100
|
+
export { useHotkeysContext, HotkeysProvider, isHotkeyPressed } from 'react-hotkeys-hook';
|
|
101
|
+
|
|
102
|
+
// Re-export types
|
|
103
|
+
export type { HotkeyCallback, Keys } from 'react-hotkeys-hook';
|
|
@@ -2,56 +2,67 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
|
|
5
|
-
export type
|
|
5
|
+
export type ResolvedTheme = 'light' | 'dark';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Hook to detect
|
|
9
|
-
*
|
|
8
|
+
* Hook to detect the current resolved theme (light or dark)
|
|
9
|
+
*
|
|
10
|
+
* Standalone hook - doesn't require ThemeProvider.
|
|
11
|
+
* Detects theme from:
|
|
12
|
+
* 1. 'dark' class on html element
|
|
13
|
+
* 2. System preference (prefers-color-scheme)
|
|
14
|
+
*
|
|
15
|
+
* For full theme control (setTheme, toggleTheme), use useThemeContext instead.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* const theme = useResolvedTheme(); // 'light' | 'dark'
|
|
20
|
+
* ```
|
|
10
21
|
*/
|
|
11
|
-
export const
|
|
12
|
-
const [theme, setTheme] = useState<
|
|
13
|
-
|
|
22
|
+
export const useResolvedTheme = (): ResolvedTheme => {
|
|
23
|
+
const [theme, setTheme] = useState<ResolvedTheme>('light');
|
|
24
|
+
|
|
14
25
|
useEffect(() => {
|
|
15
|
-
const checkTheme = ():
|
|
16
|
-
// Check if dark class is applied to html element
|
|
26
|
+
const checkTheme = (): ResolvedTheme => {
|
|
27
|
+
// Check if dark class is applied to html element
|
|
17
28
|
if (document.documentElement.classList.contains('dark')) {
|
|
18
29
|
return 'dark';
|
|
19
30
|
}
|
|
20
|
-
|
|
31
|
+
|
|
21
32
|
// Check system preference
|
|
22
33
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
23
34
|
return 'dark';
|
|
24
35
|
}
|
|
25
|
-
|
|
36
|
+
|
|
26
37
|
return 'light';
|
|
27
38
|
};
|
|
28
|
-
|
|
39
|
+
|
|
29
40
|
// Set initial theme
|
|
30
41
|
setTheme(checkTheme());
|
|
31
|
-
|
|
32
|
-
// Listen for
|
|
42
|
+
|
|
43
|
+
// Listen for class changes on html element
|
|
33
44
|
const observer = new MutationObserver(() => {
|
|
34
45
|
setTheme(checkTheme());
|
|
35
46
|
});
|
|
36
|
-
|
|
47
|
+
|
|
37
48
|
observer.observe(document.documentElement, {
|
|
38
49
|
attributes: true,
|
|
39
50
|
attributeFilter: ['class']
|
|
40
51
|
});
|
|
41
|
-
|
|
52
|
+
|
|
42
53
|
// Listen for system theme changes
|
|
43
54
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
44
55
|
const handleMediaChange = () => {
|
|
45
56
|
setTheme(checkTheme());
|
|
46
57
|
};
|
|
47
|
-
|
|
58
|
+
|
|
48
59
|
mediaQuery.addEventListener('change', handleMediaChange);
|
|
49
|
-
|
|
60
|
+
|
|
50
61
|
return () => {
|
|
51
62
|
observer.disconnect();
|
|
52
63
|
mediaQuery.removeEventListener('change', handleMediaChange);
|
|
53
64
|
};
|
|
54
65
|
}, []);
|
|
55
|
-
|
|
66
|
+
|
|
56
67
|
return theme;
|
|
57
68
|
};
|
|
@@ -1,82 +1,76 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ThemeProvider - Universal theme management
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Re-exports next-themes ThemeProvider with sensible defaults.
|
|
5
|
+
* Supports light, dark, and system themes with localStorage persistence.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* // In app/layout.tsx
|
|
10
|
+
* import { ThemeProvider } from '@djangocfg/ui-nextjs';
|
|
11
|
+
*
|
|
12
|
+
* <ThemeProvider
|
|
13
|
+
* attribute="class"
|
|
14
|
+
* defaultTheme="system"
|
|
15
|
+
* enableSystem
|
|
16
|
+
* >
|
|
17
|
+
* {children}
|
|
18
|
+
* </ThemeProvider>
|
|
19
|
+
* ```
|
|
5
20
|
*/
|
|
6
21
|
|
|
7
22
|
'use client';
|
|
8
23
|
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
13
|
-
// Types
|
|
14
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
type Theme = 'light' | 'dark';
|
|
17
|
-
|
|
18
|
-
interface ThemeContextValue {
|
|
19
|
-
theme: Theme;
|
|
20
|
-
setTheme: (theme: Theme) => void;
|
|
21
|
-
toggleTheme: () => void;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
25
|
-
// Create Context
|
|
26
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
24
|
+
import { ThemeProvider as NextThemesProvider, useTheme as useNextTheme } from 'next-themes';
|
|
25
|
+
import type { ThemeProviderProps as NextThemesProviderProps } from 'next-themes';
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// Provider Component
|
|
32
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
interface ThemeProviderProps {
|
|
35
|
-
children: ReactNode;
|
|
36
|
-
defaultTheme?: Theme;
|
|
37
|
-
storageKey?: string;
|
|
38
|
-
}
|
|
27
|
+
// Re-export types
|
|
28
|
+
export type Theme = 'light' | 'dark' | 'system';
|
|
29
|
+
export type ThemeProviderProps = NextThemesProviderProps;
|
|
39
30
|
|
|
31
|
+
/**
|
|
32
|
+
* ThemeProvider wraps next-themes with sensible defaults
|
|
33
|
+
*/
|
|
40
34
|
export function ThemeProvider({
|
|
41
35
|
children,
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
attribute = 'class',
|
|
37
|
+
defaultTheme = 'system',
|
|
38
|
+
enableSystem = true,
|
|
39
|
+
disableTransitionOnChange = true,
|
|
40
|
+
...props
|
|
44
41
|
}: ThemeProviderProps) {
|
|
45
|
-
|
|
42
|
+
return (
|
|
43
|
+
<NextThemesProvider
|
|
44
|
+
attribute={attribute}
|
|
45
|
+
defaultTheme={defaultTheme}
|
|
46
|
+
enableSystem={enableSystem}
|
|
47
|
+
disableTransitionOnChange={disableTransitionOnChange}
|
|
48
|
+
{...props}
|
|
49
|
+
>
|
|
50
|
+
{children}
|
|
51
|
+
</NextThemesProvider>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
46
54
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Hook to access theme context
|
|
57
|
+
* Returns theme, setTheme, resolvedTheme, systemTheme, and themes
|
|
58
|
+
*/
|
|
59
|
+
export function useThemeContext() {
|
|
60
|
+
const { theme, setTheme, resolvedTheme, systemTheme, themes } = useNextTheme();
|
|
52
61
|
|
|
53
62
|
const toggleTheme = () => {
|
|
54
|
-
|
|
63
|
+
// Toggle between light and dark (ignore system)
|
|
64
|
+
const currentResolved = resolvedTheme || 'light';
|
|
65
|
+
setTheme(currentResolved === 'light' ? 'dark' : 'light');
|
|
55
66
|
};
|
|
56
67
|
|
|
57
|
-
|
|
58
|
-
theme,
|
|
68
|
+
return {
|
|
69
|
+
theme: theme as Theme | undefined,
|
|
59
70
|
setTheme,
|
|
71
|
+
resolvedTheme: resolvedTheme as 'light' | 'dark' | undefined,
|
|
72
|
+
systemTheme: systemTheme as 'light' | 'dark' | undefined,
|
|
73
|
+
themes,
|
|
60
74
|
toggleTheme,
|
|
61
75
|
};
|
|
62
|
-
|
|
63
|
-
return (
|
|
64
|
-
<ThemeContext.Provider value={value}>
|
|
65
|
-
{children}
|
|
66
|
-
</ThemeContext.Provider>
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
71
|
-
// Custom Hook
|
|
72
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
73
|
-
|
|
74
|
-
export function useThemeContext(): ThemeContextValue {
|
|
75
|
-
const context = useContext(ThemeContext);
|
|
76
|
-
|
|
77
|
-
if (context === undefined) {
|
|
78
|
-
throw new Error('useThemeContext must be used within ThemeProvider');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return context;
|
|
82
76
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ThemeToggle - Theme switcher component
|
|
3
3
|
*
|
|
4
|
-
* Switches between light and dark themes
|
|
5
|
-
*
|
|
4
|
+
* Switches between light and dark themes.
|
|
5
|
+
* Must be used within ThemeProvider.
|
|
6
6
|
*
|
|
7
7
|
* @example
|
|
8
8
|
* ```tsx
|
|
@@ -20,17 +20,21 @@ import { Button } from '@djangocfg/ui-core/components';
|
|
|
20
20
|
import { useThemeContext } from './ThemeProvider';
|
|
21
21
|
|
|
22
22
|
export function ThemeToggle() {
|
|
23
|
-
const {
|
|
24
|
-
const [
|
|
23
|
+
const { resolvedTheme, toggleTheme } = useThemeContext();
|
|
24
|
+
const [mounted, setMounted] = useState(false);
|
|
25
25
|
|
|
26
|
-
// Prevent hydration mismatch
|
|
26
|
+
// Prevent hydration mismatch
|
|
27
27
|
useEffect(() => {
|
|
28
|
-
|
|
28
|
+
setMounted(true);
|
|
29
29
|
}, []);
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
if (!mounted) {
|
|
32
|
+
return (
|
|
33
|
+
<Button variant="ghost" size="icon" className="h-9 w-9" disabled>
|
|
34
|
+
<Sun className="h-4 w-4" />
|
|
35
|
+
<span className="sr-only">Toggle theme</span>
|
|
36
|
+
</Button>
|
|
37
|
+
);
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
return (
|
|
@@ -39,9 +43,9 @@ export function ThemeToggle() {
|
|
|
39
43
|
size="icon"
|
|
40
44
|
onClick={toggleTheme}
|
|
41
45
|
className="h-9 w-9"
|
|
42
|
-
title={`Switch to ${
|
|
46
|
+
title={`Switch to ${resolvedTheme === 'light' ? 'dark' : 'light'} theme`}
|
|
43
47
|
>
|
|
44
|
-
{
|
|
48
|
+
{resolvedTheme === 'light' ? (
|
|
45
49
|
<Sun className="h-4 w-4" />
|
|
46
50
|
) : (
|
|
47
51
|
<Moon className="h-4 w-4" />
|
package/src/theme/index.ts
CHANGED