@djangocfg/ui-core 2.1.227 → 2.1.228
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/README.md +18 -3
- package/package.json +6 -6
- package/src/components/calendar/date-picker.tsx +3 -3
- package/src/components/carousel.tsx +3 -3
- package/src/components/checkbox.tsx +6 -2
- package/src/components/combobox.tsx +2 -2
- package/src/components/copy.tsx +2 -2
- package/src/components/effects/GlowBackground.tsx +78 -0
- package/src/components/effects/index.ts +2 -0
- package/src/components/index.ts +4 -0
- package/src/components/input-otp.tsx +1 -1
- package/src/components/multi-select-pro/async.tsx +2 -2
- package/src/components/multi-select-pro/index.tsx +2 -2
- package/src/components/multi-select.tsx +2 -2
- package/src/components/otp/index.tsx +21 -6
- package/src/components/otp/types.ts +8 -2
- package/src/components/phone-input.tsx +2 -2
- package/src/components/preloader.tsx +2 -2
- package/src/components/spinner.tsx +2 -2
- package/src/components/tabs.tsx +2 -2
- package/src/hooks/index.ts +5 -3
- package/src/hooks/useCountdown.ts +90 -50
- package/src/hooks/useMediaQuery.ts +30 -27
- package/src/hooks/useMobile.tsx +28 -15
- package/src/styles/theme/animations.css +7 -0
- package/src/styles/theme/tokens.css +1 -0
package/README.md
CHANGED
|
@@ -45,12 +45,14 @@ pnpm add @djangocfg/ui-core
|
|
|
45
45
|
### Specialized (13)
|
|
46
46
|
`ButtonGroup` `Empty` `Spinner` `Preloader` `Kbd` `TokenIcon` `InputGroup` `Item` `ImageWithFallback` `OgImage` `CopyButton` `CopyField` `StaticPagination`
|
|
47
47
|
|
|
48
|
-
## Hooks (
|
|
48
|
+
## Hooks (16)
|
|
49
49
|
|
|
50
50
|
| Hook | Description |
|
|
51
51
|
|------|-------------|
|
|
52
|
-
| `useMediaQuery` |
|
|
53
|
-
| `
|
|
52
|
+
| `useMediaQuery(query)` | Raw media query — pass any CSS query string. Exports `BREAKPOINTS` constants (Tailwind v4 defaults) |
|
|
53
|
+
| `useIsPhone()` | `< 640px` — phones only |
|
|
54
|
+
| `useIsMobile()` | `< 768px` — phones + small tablets |
|
|
55
|
+
| `useIsTabletOrBelow()` | `< 1024px` — phones + tablets |
|
|
54
56
|
| `useCopy` | Copy to clipboard |
|
|
55
57
|
| `useCountdown` | Countdown timer |
|
|
56
58
|
| `useDebounce` | Debounce values |
|
|
@@ -62,6 +64,19 @@ pnpm add @djangocfg/ui-core
|
|
|
62
64
|
| `useHotkey` | Keyboard shortcuts (react-hotkeys-hook) |
|
|
63
65
|
| `useBrowserDetect` | Browser detection (Chrome, Safari, in-app browsers, etc.) |
|
|
64
66
|
| `useDeviceDetect` | Device detection (mobile, tablet, desktop, OS, etc.) |
|
|
67
|
+
| `useResolvedTheme` | Current resolved theme (light/dark/system) |
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
import { useMediaQuery, useIsPhone, useIsMobile, BREAKPOINTS } from '@djangocfg/ui-core/hooks'
|
|
71
|
+
|
|
72
|
+
// semantic
|
|
73
|
+
const isPhone = useIsPhone() // < 640px
|
|
74
|
+
const isMobile = useIsMobile() // < 768px
|
|
75
|
+
|
|
76
|
+
// custom with constants
|
|
77
|
+
const isNarrow = useMediaQuery(`(max-width: ${BREAKPOINTS.sm - 1}px)`)
|
|
78
|
+
const isDark = useMediaQuery('(prefers-color-scheme: dark)')
|
|
79
|
+
```
|
|
65
80
|
|
|
66
81
|
## Theme Palette Hooks
|
|
67
82
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.228",
|
|
4
4
|
"description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -81,12 +81,12 @@
|
|
|
81
81
|
"playground": "playground dev"
|
|
82
82
|
},
|
|
83
83
|
"peerDependencies": {
|
|
84
|
-
"@djangocfg/i18n": "^2.1.
|
|
85
|
-
"react-device-detect": "^2.2.3",
|
|
84
|
+
"@djangocfg/i18n": "^2.1.228",
|
|
86
85
|
"consola": "^3.4.2",
|
|
87
86
|
"lucide-react": "^0.545.0",
|
|
88
87
|
"moment": "^2.30.1",
|
|
89
88
|
"react": "^19.1.0",
|
|
89
|
+
"react-device-detect": "^2.2.3",
|
|
90
90
|
"react-dom": "^19.1.0",
|
|
91
91
|
"react-hook-form": "^7.69.0",
|
|
92
92
|
"tailwindcss": "^4.1.18",
|
|
@@ -134,18 +134,18 @@
|
|
|
134
134
|
"input-otp": "1.4.2",
|
|
135
135
|
"libphonenumber-js": "^1.12.24",
|
|
136
136
|
"react-day-picker": "9.11.1",
|
|
137
|
+
"react-hotkeys-hook": "^4.6.1",
|
|
137
138
|
"react-resizable-panels": "3.0.6",
|
|
138
139
|
"react-sticky-box": "^2.0.5",
|
|
139
140
|
"recharts": "2.15.4",
|
|
140
141
|
"sonner": "2.0.7",
|
|
141
|
-
"react-hotkeys-hook": "^4.6.1",
|
|
142
142
|
"tailwind-merge": "^3.3.1",
|
|
143
143
|
"vaul": "1.1.2"
|
|
144
144
|
},
|
|
145
145
|
"devDependencies": {
|
|
146
|
-
"@djangocfg/i18n": "^2.1.
|
|
146
|
+
"@djangocfg/i18n": "^2.1.228",
|
|
147
147
|
"@djangocfg/playground": "workspace:*",
|
|
148
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
148
|
+
"@djangocfg/typescript-config": "^2.1.228",
|
|
149
149
|
"@types/node": "^24.7.2",
|
|
150
150
|
"@types/react": "^19.1.0",
|
|
151
151
|
"@types/react-dom": "^19.1.0",
|
|
@@ -4,7 +4,7 @@ import { format } from 'date-fns';
|
|
|
4
4
|
import { CalendarIcon } from 'lucide-react';
|
|
5
5
|
import * as React from 'react';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
8
8
|
import { cn } from '../../lib/utils';
|
|
9
9
|
import { Button } from '../button';
|
|
10
10
|
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
|
|
@@ -55,7 +55,7 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
|
|
|
55
55
|
},
|
|
56
56
|
ref
|
|
57
57
|
) => {
|
|
58
|
-
const t =
|
|
58
|
+
const t = useAppT()
|
|
59
59
|
const [open, setOpen] = React.useState(false)
|
|
60
60
|
|
|
61
61
|
const resolvedPlaceholder = placeholder ?? t('ui.datetime.pickDate')
|
|
@@ -148,7 +148,7 @@ const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePickerProps
|
|
|
148
148
|
},
|
|
149
149
|
ref
|
|
150
150
|
) => {
|
|
151
|
-
const t =
|
|
151
|
+
const t = useAppT()
|
|
152
152
|
const [open, setOpen] = React.useState(false)
|
|
153
153
|
|
|
154
154
|
const resolvedPlaceholder = placeholder ?? t('ui.datetime.pickDateRange')
|
|
@@ -5,7 +5,7 @@ import * as React from 'react';
|
|
|
5
5
|
|
|
6
6
|
import { ArrowLeftIcon, ArrowRightIcon } from '@radix-ui/react-icons';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
9
9
|
import { cn } from '../lib/utils';
|
|
10
10
|
import { Button } from './button';
|
|
11
11
|
|
|
@@ -198,7 +198,7 @@ const CarouselPrevious = React.forwardRef<
|
|
|
198
198
|
HTMLButtonElement,
|
|
199
199
|
React.ComponentProps<typeof Button>
|
|
200
200
|
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
|
201
|
-
const t =
|
|
201
|
+
const t = useAppT()
|
|
202
202
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
|
203
203
|
const previousLabel = t('ui.actions.previousSlide')
|
|
204
204
|
|
|
@@ -229,7 +229,7 @@ const CarouselNext = React.forwardRef<
|
|
|
229
229
|
HTMLButtonElement,
|
|
230
230
|
React.ComponentProps<typeof Button>
|
|
231
231
|
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
|
232
|
-
const t =
|
|
232
|
+
const t = useAppT()
|
|
233
233
|
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
|
234
234
|
const nextLabel = t('ui.actions.nextSlide')
|
|
235
235
|
|
|
@@ -14,7 +14,11 @@ const Checkbox = React.forwardRef<
|
|
|
14
14
|
<CheckboxPrimitive.Root
|
|
15
15
|
ref={ref}
|
|
16
16
|
className={cn(
|
|
17
|
-
"peer h-
|
|
17
|
+
"peer h-[1.125rem] w-[1.125rem] shrink-0 rounded-[4px] border border-input bg-background shadow-none",
|
|
18
|
+
"transition-colors duration-150",
|
|
19
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
|
20
|
+
"disabled:cursor-not-allowed disabled:opacity-40",
|
|
21
|
+
"data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
|
18
22
|
className
|
|
19
23
|
)}
|
|
20
24
|
{...props}
|
|
@@ -22,7 +26,7 @@ const Checkbox = React.forwardRef<
|
|
|
22
26
|
<CheckboxPrimitive.Indicator
|
|
23
27
|
className={cn("flex items-center justify-center text-current")}
|
|
24
28
|
>
|
|
25
|
-
<CheckIcon className="h-
|
|
29
|
+
<CheckIcon className="h-3 w-3" />
|
|
26
30
|
</CheckboxPrimitive.Indicator>
|
|
27
31
|
</CheckboxPrimitive.Root>
|
|
28
32
|
))
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
|
4
4
|
import * as React from 'react';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
7
7
|
import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../hooks/useStoredValue';
|
|
8
8
|
import { cn } from '../lib/utils';
|
|
9
9
|
import { Button } from './button';
|
|
@@ -78,7 +78,7 @@ export function Combobox({
|
|
|
78
78
|
storageTtl,
|
|
79
79
|
autoSelectFromOptions = false,
|
|
80
80
|
}: ComboboxProps) {
|
|
81
|
-
const t =
|
|
81
|
+
const t = useAppT()
|
|
82
82
|
const [open, setOpen] = React.useState(false)
|
|
83
83
|
const [search, setSearch] = React.useState("")
|
|
84
84
|
const scrollRef = React.useRef<HTMLDivElement>(null)
|
package/src/components/copy.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { Check, Copy } from 'lucide-react';
|
|
4
4
|
import * as React from 'react';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
7
7
|
import { cn } from '../lib/utils';
|
|
8
8
|
import { Button } from './button';
|
|
9
9
|
|
|
@@ -40,7 +40,7 @@ const CopyButton = React.forwardRef<HTMLButtonElement, CopyButtonProps>(
|
|
|
40
40
|
iconClassName = 'h-4 w-4',
|
|
41
41
|
...props
|
|
42
42
|
}, ref) => {
|
|
43
|
-
const t =
|
|
43
|
+
const t = useAppT()
|
|
44
44
|
const [copied, setCopied] = React.useState(false)
|
|
45
45
|
|
|
46
46
|
const handleCopy = async () => {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
|
|
5
|
+
export interface GlowBackgroundProps {
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GlowBackground — animated mesh gradient backdrop.
|
|
11
|
+
*
|
|
12
|
+
* Renders as `position: absolute inset-0` — must be placed inside
|
|
13
|
+
* a `position: relative` container. Content goes on top with `position: relative`.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* <div style={{ position: 'relative', minHeight: '100vh' }}>
|
|
18
|
+
* <GlowBackground />
|
|
19
|
+
* <div style={{ position: 'relative' }}>{content}</div>
|
|
20
|
+
* </div>
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export const GlowBackground = React.memo(({ className }: GlowBackgroundProps) => (
|
|
24
|
+
<div
|
|
25
|
+
aria-hidden="true"
|
|
26
|
+
style={{
|
|
27
|
+
position: 'absolute',
|
|
28
|
+
inset: 0,
|
|
29
|
+
overflow: 'hidden',
|
|
30
|
+
pointerEvents: 'none',
|
|
31
|
+
zIndex: 0,
|
|
32
|
+
}}
|
|
33
|
+
className={className}
|
|
34
|
+
>
|
|
35
|
+
{/* Blue blob — top left */}
|
|
36
|
+
<div style={{
|
|
37
|
+
position: 'absolute',
|
|
38
|
+
top: '-10%',
|
|
39
|
+
left: '-10%',
|
|
40
|
+
width: 700,
|
|
41
|
+
height: 700,
|
|
42
|
+
borderRadius: '50%',
|
|
43
|
+
background: 'radial-gradient(circle, hsl(217 91% 60% / 0.5) 0%, transparent 70%)',
|
|
44
|
+
filter: 'blur(80px)',
|
|
45
|
+
animation: 'blob 10s ease-in-out infinite',
|
|
46
|
+
}} />
|
|
47
|
+
|
|
48
|
+
{/* Purple blob — bottom right */}
|
|
49
|
+
<div style={{
|
|
50
|
+
position: 'absolute',
|
|
51
|
+
bottom: '-10%',
|
|
52
|
+
right: '-10%',
|
|
53
|
+
width: 600,
|
|
54
|
+
height: 600,
|
|
55
|
+
borderRadius: '50%',
|
|
56
|
+
background: 'radial-gradient(circle, hsl(262 83% 65% / 0.4) 0%, transparent 70%)',
|
|
57
|
+
filter: 'blur(80px)',
|
|
58
|
+
animation: 'blob 10s ease-in-out infinite',
|
|
59
|
+
animationDelay: '3s',
|
|
60
|
+
}} />
|
|
61
|
+
|
|
62
|
+
{/* Indigo blob — center top */}
|
|
63
|
+
<div style={{
|
|
64
|
+
position: 'absolute',
|
|
65
|
+
top: '10%',
|
|
66
|
+
right: '20%',
|
|
67
|
+
width: 400,
|
|
68
|
+
height: 400,
|
|
69
|
+
borderRadius: '50%',
|
|
70
|
+
background: 'radial-gradient(circle, hsl(234 89% 74% / 0.35) 0%, transparent 70%)',
|
|
71
|
+
filter: 'blur(60px)',
|
|
72
|
+
animation: 'blob 12s ease-in-out infinite',
|
|
73
|
+
animationDelay: '6s',
|
|
74
|
+
}} />
|
|
75
|
+
</div>
|
|
76
|
+
));
|
|
77
|
+
|
|
78
|
+
GlowBackground.displayName = 'GlowBackground';
|
package/src/components/index.ts
CHANGED
|
@@ -96,6 +96,10 @@ export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGro
|
|
|
96
96
|
export { Item, ItemMedia, ItemContent, ItemActions, ItemGroup, ItemSeparator, ItemTitle, ItemDescription, ItemHeader, ItemFooter } from './item';
|
|
97
97
|
export { Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldLegend, FieldSeparator, FieldSet, FieldContent, FieldTitle } from './field';
|
|
98
98
|
|
|
99
|
+
// Effects / Background
|
|
100
|
+
export { GlowBackground } from './effects';
|
|
101
|
+
export type { GlowBackgroundProps } from './effects';
|
|
102
|
+
|
|
99
103
|
export { CopyButton, CopyField } from './copy';
|
|
100
104
|
export type { CopyButtonProps, CopyFieldProps } from './copy';
|
|
101
105
|
export { DownloadButton } from './button-download';
|
|
@@ -53,7 +53,7 @@ const InputOTPSlot = React.forwardRef<
|
|
|
53
53
|
<div
|
|
54
54
|
ref={ref}
|
|
55
55
|
className={cn(
|
|
56
|
-
"relative flex
|
|
56
|
+
"relative flex items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
|
57
57
|
isActive && "z-10 ring-1 ring-ring",
|
|
58
58
|
className
|
|
59
59
|
)}
|
|
@@ -17,7 +17,7 @@ import { cva } from 'class-variance-authority';
|
|
|
17
17
|
import { Check, ChevronsUpDown, Loader2, X, XCircle } from 'lucide-react';
|
|
18
18
|
import * as React from 'react';
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
21
21
|
import { Badge } from '../badge';
|
|
22
22
|
import { Button } from '../button';
|
|
23
23
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from '../command';
|
|
@@ -197,7 +197,7 @@ export const MultiSelectProAsync = React.forwardRef<MultiSelectProAsyncRef, Mult
|
|
|
197
197
|
},
|
|
198
198
|
ref
|
|
199
199
|
) => {
|
|
200
|
-
const t =
|
|
200
|
+
const t = useAppT()
|
|
201
201
|
const [open, setOpen] = React.useState(false)
|
|
202
202
|
const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue)
|
|
203
203
|
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
|
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|
|
4
4
|
import { Check, ChevronsUpDown, X, XCircle } from 'lucide-react';
|
|
5
5
|
import * as React from 'react';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
8
8
|
import { Badge } from '../badge';
|
|
9
9
|
import { Button } from '../button';
|
|
10
10
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from '../command';
|
|
@@ -174,7 +174,7 @@ export const MultiSelectPro = React.forwardRef<MultiSelectProRef, MultiSelectPro
|
|
|
174
174
|
},
|
|
175
175
|
ref
|
|
176
176
|
) => {
|
|
177
|
-
const t =
|
|
177
|
+
const t = useAppT()
|
|
178
178
|
const [open, setOpen] = React.useState(false)
|
|
179
179
|
const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue)
|
|
180
180
|
const [search, setSearch] = React.useState("")
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { Check, ChevronsUpDown, X } from 'lucide-react';
|
|
4
4
|
import * as React from 'react';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
7
7
|
import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../hooks/useStoredValue';
|
|
8
8
|
import { cn } from '../lib/utils';
|
|
9
9
|
import { Badge } from './badge';
|
|
@@ -75,7 +75,7 @@ export function MultiSelect({
|
|
|
75
75
|
storageTtl,
|
|
76
76
|
autoSelectFromOptions = false,
|
|
77
77
|
}: MultiSelectProps) {
|
|
78
|
-
const t =
|
|
78
|
+
const t = useAppT()
|
|
79
79
|
const [open, setOpen] = React.useState(false)
|
|
80
80
|
const [search, setSearch] = React.useState("")
|
|
81
81
|
const scrollRef = React.useRef<HTMLDivElement>(null)
|
|
@@ -5,13 +5,16 @@ import * as React from 'react';
|
|
|
5
5
|
|
|
6
6
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from '../input-otp';
|
|
7
7
|
import { cn } from '../../lib';
|
|
8
|
+
import { useIsMobile } from '../../hooks/useMobile';
|
|
8
9
|
|
|
9
10
|
import { createPasteHandler, useSmartOTP } from './use-otp-input';
|
|
10
11
|
|
|
11
12
|
import type { SmartOTPProps } from './types'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
* Size variants for OTP slots
|
|
15
|
+
* Size variants for OTP slots.
|
|
16
|
+
* In fluid mode these only set font-size — width/height are driven by the container.
|
|
17
|
+
* In fixed mode they set explicit w/h dimensions.
|
|
15
18
|
*/
|
|
16
19
|
const sizeVariants = {
|
|
17
20
|
sm: 'h-8 w-8 text-sm',
|
|
@@ -19,6 +22,12 @@ const sizeVariants = {
|
|
|
19
22
|
lg: 'h-14 w-14 text-2xl',
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
const sizeTextVariants = {
|
|
26
|
+
sm: 'text-sm',
|
|
27
|
+
default: 'text-base',
|
|
28
|
+
lg: 'text-2xl',
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
/**
|
|
23
32
|
* OTP Separator Component
|
|
24
33
|
*/
|
|
@@ -85,13 +94,17 @@ export const OTPInput = React.forwardRef<
|
|
|
85
94
|
slotClassName,
|
|
86
95
|
separatorClassName,
|
|
87
96
|
autoFocus = true,
|
|
88
|
-
size
|
|
97
|
+
size,
|
|
89
98
|
error = false,
|
|
90
99
|
success = false,
|
|
100
|
+
fluid = false,
|
|
91
101
|
...props
|
|
92
102
|
},
|
|
93
103
|
ref
|
|
94
104
|
) => {
|
|
105
|
+
const isMobile = useIsMobile()
|
|
106
|
+
const resolvedSize = size ?? (isMobile ? 'default' : 'lg')
|
|
107
|
+
|
|
95
108
|
const {
|
|
96
109
|
value: otpValue,
|
|
97
110
|
handleChange,
|
|
@@ -143,7 +156,9 @@ export const OTPInput = React.forwardRef<
|
|
|
143
156
|
key={i}
|
|
144
157
|
index={i}
|
|
145
158
|
className={cn(
|
|
146
|
-
|
|
159
|
+
fluid
|
|
160
|
+
? `flex-1 min-w-0 aspect-square ${sizeTextVariants[resolvedSize]}`
|
|
161
|
+
: sizeVariants[resolvedSize],
|
|
147
162
|
error && 'border-destructive ring-destructive/20',
|
|
148
163
|
success && 'border-green-500 ring-green-500/20',
|
|
149
164
|
slotClassName
|
|
@@ -153,7 +168,7 @@ export const OTPInput = React.forwardRef<
|
|
|
153
168
|
}
|
|
154
169
|
|
|
155
170
|
return slotElements
|
|
156
|
-
}, [length, showSeparator, separatorPosition, separatorClassName,
|
|
171
|
+
}, [length, showSeparator, separatorPosition, separatorClassName, resolvedSize, fluid, error, success, slotClassName])
|
|
157
172
|
|
|
158
173
|
return (
|
|
159
174
|
<InputOTP
|
|
@@ -163,12 +178,12 @@ export const OTPInput = React.forwardRef<
|
|
|
163
178
|
onChange={handleChange}
|
|
164
179
|
onComplete={handleComplete}
|
|
165
180
|
disabled={disabled}
|
|
166
|
-
containerClassName={cn('gap-2', containerClassName)}
|
|
181
|
+
containerClassName={cn('gap-2', fluid && 'w-full', containerClassName)}
|
|
167
182
|
autoFocus={autoFocus}
|
|
168
183
|
onPaste={pasteHandler}
|
|
169
184
|
{...props}
|
|
170
185
|
>
|
|
171
|
-
<InputOTPGroup>{slots}</InputOTPGroup>
|
|
186
|
+
<InputOTPGroup className={cn(fluid && 'w-full')}>{slots}</InputOTPGroup>
|
|
172
187
|
</InputOTP>
|
|
173
188
|
)
|
|
174
189
|
}
|
|
@@ -105,8 +105,7 @@ export interface SmartOTPProps {
|
|
|
105
105
|
autoFocus?: boolean
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
* Slot size variant
|
|
109
|
-
* @default 'default'
|
|
108
|
+
* Slot size variant. When omitted, auto-detects: 'default' on mobile, 'lg' on desktop.
|
|
110
109
|
*/
|
|
111
110
|
size?: 'sm' | 'default' | 'lg'
|
|
112
111
|
|
|
@@ -119,6 +118,13 @@ export interface SmartOTPProps {
|
|
|
119
118
|
* Success state
|
|
120
119
|
*/
|
|
121
120
|
success?: boolean
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Fluid mode — slots stretch to fill the full container width.
|
|
124
|
+
* Useful for responsive layouts where fixed slot widths would overflow.
|
|
125
|
+
* @default false
|
|
126
|
+
*/
|
|
127
|
+
fluid?: boolean
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
/**
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
import { ChevronDown, Search } from 'lucide-react';
|
|
7
7
|
import * as React from 'react';
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
10
10
|
import { Button } from './button';
|
|
11
11
|
import { Input } from './input';
|
|
12
12
|
import { cn } from '../lib';
|
|
@@ -63,7 +63,7 @@ const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
|
|
|
63
63
|
disabled = false,
|
|
64
64
|
...props
|
|
65
65
|
}, ref) => {
|
|
66
|
-
const t =
|
|
66
|
+
const t = useAppT()
|
|
67
67
|
const [selectedCountry, setSelectedCountry] = React.useState<CountryCode>(defaultCountry)
|
|
68
68
|
const [inputValue, setInputValue] = React.useState('')
|
|
69
69
|
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false)
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { Loader2 } from 'lucide-react';
|
|
11
11
|
import React from 'react';
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
14
14
|
import { cn } from '../lib/utils';
|
|
15
15
|
// Spinner is available for future use
|
|
16
16
|
// import { Spinner } from './spinner';
|
|
@@ -104,7 +104,7 @@ export function Preloader({
|
|
|
104
104
|
className,
|
|
105
105
|
spinnerClassName,
|
|
106
106
|
}: PreloaderProps) {
|
|
107
|
-
const t =
|
|
107
|
+
const t = useAppT()
|
|
108
108
|
const loadingLabel = text || t('ui.states.loading')
|
|
109
109
|
const spinnerSize = sizeMap[size];
|
|
110
110
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Loader2Icon } from 'lucide-react';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
4
4
|
import { cn } from '../lib/utils';
|
|
5
5
|
|
|
6
6
|
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
|
7
|
-
const t =
|
|
7
|
+
const t = useAppT()
|
|
8
8
|
const loadingLabel = t('ui.states.loading')
|
|
9
9
|
|
|
10
10
|
return (
|
package/src/components/tabs.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import * as TabsPrimitive from '@radix-ui/react-tabs';
|
|
|
7
7
|
|
|
8
8
|
import { useIsMobile } from '../hooks';
|
|
9
9
|
import { useStoredValue, type StorageType } from '../hooks/useStoredValue';
|
|
10
|
-
import {
|
|
10
|
+
import { useAppT } from '@djangocfg/i18n';
|
|
11
11
|
import { cn } from '../lib/utils';
|
|
12
12
|
import { Button } from './button';
|
|
13
13
|
import { ScrollArea, ScrollBar } from './scroll-area';
|
|
@@ -54,7 +54,7 @@ const Tabs = React.forwardRef<
|
|
|
54
54
|
React.ElementRef<typeof TabsPrimitive.Root>,
|
|
55
55
|
TabsProps
|
|
56
56
|
>(({ mobileSheet = false, mobileSheetTitle, mobileTitleText, sticky = false, storageKey, storageType, children, ...props }, ref) => {
|
|
57
|
-
const t =
|
|
57
|
+
const t = useAppT()
|
|
58
58
|
const resolvedMobileSheetTitle = mobileSheetTitle ?? t('ui.navigation.title')
|
|
59
59
|
const isMobile = useIsMobile()
|
|
60
60
|
const [open, setOpen] = React.useState(false)
|
package/src/hooks/index.ts
CHANGED
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
|
|
6
6
|
'use client';
|
|
7
7
|
|
|
8
|
-
export { useCountdown } from './useCountdown';
|
|
8
|
+
export { useCountdown, useCountdownFromSeconds } from './useCountdown';
|
|
9
|
+
export type { CountdownState } from './useCountdown';
|
|
9
10
|
export { useDebouncedCallback } from './useDebouncedCallback';
|
|
10
11
|
export { useDebounce } from './useDebounce';
|
|
11
12
|
export { useDebugTools } from './useDebugTools';
|
|
12
13
|
export { useEventListener, events } from './useEventsBus';
|
|
13
|
-
export { useIsMobile } from './useMobile';
|
|
14
|
-
export {
|
|
14
|
+
export { useIsMobile, useIsPhone, useIsTabletOrBelow, BREAKPOINTS } from './useMobile';
|
|
15
|
+
export type { Breakpoint } from './useMobile';
|
|
16
|
+
export { useMediaQuery, BREAKPOINTS as MEDIA_BREAKPOINTS } from './useMediaQuery';
|
|
15
17
|
export { useCopy } from './useCopy';
|
|
16
18
|
export { useImageLoader } from './useImageLoader';
|
|
17
19
|
export { useToast, toast } from './useToast';
|
|
@@ -1,73 +1,113 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import moment from 'moment';
|
|
4
|
-
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
5
5
|
|
|
6
|
-
interface CountdownState {
|
|
6
|
+
export interface CountdownState {
|
|
7
7
|
days: number;
|
|
8
8
|
hours: number;
|
|
9
9
|
minutes: number;
|
|
10
10
|
seconds: number;
|
|
11
11
|
isExpired: boolean;
|
|
12
12
|
totalSeconds: number;
|
|
13
|
+
/** Formatted MM:SS label, e.g. "19:00" */
|
|
14
|
+
label: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
const ZERO_STATE: CountdownState = {
|
|
18
|
+
days: 0,
|
|
19
|
+
hours: 0,
|
|
20
|
+
minutes: 0,
|
|
21
|
+
seconds: 0,
|
|
22
|
+
isExpired: true,
|
|
23
|
+
totalSeconds: 0,
|
|
24
|
+
label: '',
|
|
25
|
+
};
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
function formatLabel(diff: number): string {
|
|
28
|
+
const m = Math.floor(diff / 60);
|
|
29
|
+
const s = diff % 60;
|
|
30
|
+
return m > 0
|
|
31
|
+
? `${m}:${String(s).padStart(2, '0')}`
|
|
32
|
+
: `${s}s`;
|
|
33
|
+
}
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
function secondsToState(diff: number): CountdownState {
|
|
36
|
+
if (diff <= 0) return ZERO_STATE;
|
|
37
|
+
return {
|
|
38
|
+
days: Math.floor(diff / (24 * 60 * 60)),
|
|
39
|
+
hours: Math.floor((diff % (24 * 60 * 60)) / (60 * 60)),
|
|
40
|
+
minutes: Math.floor((diff % (60 * 60)) / 60),
|
|
41
|
+
seconds: diff % 60,
|
|
42
|
+
isExpired: false,
|
|
43
|
+
totalSeconds: diff,
|
|
44
|
+
label: formatLabel(diff),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
31
47
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Date-based countdown (existing API).
|
|
50
|
+
* Re-calculates every second against a UTC date string, converted to local time.
|
|
51
|
+
*/
|
|
52
|
+
export const useCountdown = (targetDate: string | null): CountdownState => {
|
|
53
|
+
const [countdown, setCountdown] = useState<CountdownState>(ZERO_STATE);
|
|
35
54
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
days: 0,
|
|
39
|
-
hours: 0,
|
|
40
|
-
minutes: 0,
|
|
41
|
-
seconds: 0,
|
|
42
|
-
isExpired: true,
|
|
43
|
-
totalSeconds: 0,
|
|
44
|
-
});
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const days = Math.floor(diff / (24 * 60 * 60));
|
|
49
|
-
const hours = Math.floor((diff % (24 * 60 * 60)) / (60 * 60));
|
|
50
|
-
const minutes = Math.floor((diff % (60 * 60)) / 60);
|
|
51
|
-
const seconds = diff % 60;
|
|
52
|
-
|
|
53
|
-
setCountdown({
|
|
54
|
-
days,
|
|
55
|
-
hours,
|
|
56
|
-
minutes,
|
|
57
|
-
seconds,
|
|
58
|
-
isExpired: false,
|
|
59
|
-
totalSeconds: diff,
|
|
60
|
-
});
|
|
61
|
-
};
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!targetDate) return;
|
|
62
57
|
|
|
63
|
-
|
|
64
|
-
updateCountdown();
|
|
58
|
+
const target = moment.utc(targetDate).local();
|
|
65
59
|
|
|
66
|
-
|
|
67
|
-
|
|
60
|
+
const tick = () => {
|
|
61
|
+
const diff = target.diff(moment(), 'seconds');
|
|
62
|
+
setCountdown(secondsToState(diff));
|
|
63
|
+
};
|
|
68
64
|
|
|
69
|
-
|
|
65
|
+
tick();
|
|
66
|
+
const id = setInterval(tick, 1000);
|
|
67
|
+
return () => clearInterval(id);
|
|
70
68
|
}, [targetDate]);
|
|
71
69
|
|
|
72
70
|
return countdown;
|
|
73
71
|
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Imperative seconds-based countdown with full state breakdown.
|
|
75
|
+
*
|
|
76
|
+
* @returns `[state, start]`
|
|
77
|
+
* - `state` — current countdown state (days/hours/minutes/seconds/isExpired/totalSeconds/label)
|
|
78
|
+
* - `start(n)` — begin/restart countdown from n seconds
|
|
79
|
+
*
|
|
80
|
+
* Alias: `useSecondsCountdown` (simple `[seconds, start]`)
|
|
81
|
+
*/
|
|
82
|
+
export const useCountdownFromSeconds = (): [CountdownState, (seconds: number) => void] => {
|
|
83
|
+
const [state, setState] = useState<CountdownState>(ZERO_STATE);
|
|
84
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
85
|
+
|
|
86
|
+
const clear = useCallback(() => {
|
|
87
|
+
if (intervalRef.current !== null) {
|
|
88
|
+
clearInterval(intervalRef.current);
|
|
89
|
+
intervalRef.current = null;
|
|
90
|
+
}
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
const start = useCallback(
|
|
94
|
+
(initial: number) => {
|
|
95
|
+
clear();
|
|
96
|
+
setState(secondsToState(initial));
|
|
97
|
+
intervalRef.current = setInterval(() => {
|
|
98
|
+
setState((prev) => {
|
|
99
|
+
if (prev.totalSeconds <= 1) {
|
|
100
|
+
clear();
|
|
101
|
+
return ZERO_STATE;
|
|
102
|
+
}
|
|
103
|
+
return secondsToState(prev.totalSeconds - 1);
|
|
104
|
+
});
|
|
105
|
+
}, 1000);
|
|
106
|
+
},
|
|
107
|
+
[clear],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
useEffect(() => clear, [clear]);
|
|
111
|
+
|
|
112
|
+
return [state, start];
|
|
113
|
+
};
|
|
@@ -3,38 +3,41 @@
|
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* Tailwind v4 default breakpoints (rem → px at 16px base).
|
|
7
|
+
* In v4 these are CSS variables: var(--breakpoint-sm), var(--breakpoint-md), etc.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* Usage with useMediaQuery:
|
|
10
|
+
* useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`) // < 768px
|
|
11
|
+
* useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`) // >= 1024px
|
|
12
|
+
*/
|
|
13
|
+
export const BREAKPOINTS = {
|
|
14
|
+
sm: 640, // 40rem — phones landscape
|
|
15
|
+
md: 768, // 48rem — tablets portrait
|
|
16
|
+
lg: 1024, // 64rem — tablets landscape / small laptops
|
|
17
|
+
xl: 1280, // 80rem — desktops
|
|
18
|
+
'2xl': 1536, // 96rem
|
|
19
|
+
} as const
|
|
20
|
+
|
|
21
|
+
export type Breakpoint = keyof typeof BREAKPOINTS
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reactive media query hook.
|
|
10
25
|
*
|
|
11
26
|
* @example
|
|
12
|
-
* const
|
|
13
|
-
* const
|
|
14
|
-
* const isLandscape = useMediaQuery('(orientation: landscape)')
|
|
27
|
+
* const isPhone = useMediaQuery('(max-width: 639px)')
|
|
28
|
+
* const isDark = useMediaQuery('(prefers-color-scheme: dark)')
|
|
29
|
+
* const isLandscape = useMediaQuery('(orientation: landscape)')
|
|
15
30
|
*/
|
|
16
31
|
export function useMediaQuery(query: string): boolean {
|
|
17
|
-
const [matches, setMatches] = useState<boolean>(false)
|
|
32
|
+
const [matches, setMatches] = useState<boolean>(false)
|
|
18
33
|
|
|
19
34
|
useEffect(() => {
|
|
20
|
-
const mediaQuery = window.matchMedia(query)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
// Add listener
|
|
31
|
-
mediaQuery.addEventListener('change', handler);
|
|
32
|
-
|
|
33
|
-
// Cleanup
|
|
34
|
-
return () => {
|
|
35
|
-
mediaQuery.removeEventListener('change', handler);
|
|
36
|
-
};
|
|
37
|
-
}, [query]);
|
|
38
|
-
|
|
39
|
-
return matches;
|
|
35
|
+
const mediaQuery = window.matchMedia(query)
|
|
36
|
+
setMatches(mediaQuery.matches)
|
|
37
|
+
const handler = (event: MediaQueryListEvent) => setMatches(event.matches)
|
|
38
|
+
mediaQuery.addEventListener('change', handler)
|
|
39
|
+
return () => mediaQuery.removeEventListener('change', handler)
|
|
40
|
+
}, [query])
|
|
41
|
+
|
|
42
|
+
return matches
|
|
40
43
|
}
|
package/src/hooks/useMobile.tsx
CHANGED
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Semantic viewport breakpoint hooks.
|
|
5
|
+
*
|
|
6
|
+
* Built on top of useMediaQuery — see useMediaQuery.ts for
|
|
7
|
+
* BREAKPOINTS constants and raw media query usage.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* import { useIsPhone, useIsMobile, useIsTabletOrBelow } from '@djangocfg/ui-core/hooks'
|
|
11
|
+
*
|
|
12
|
+
* const isPhone = useIsPhone() // < 640px (sm)
|
|
13
|
+
* const isMobile = useIsMobile() // < 768px (md)
|
|
14
|
+
* const isTabletOrBelow = useIsTabletOrBelow() // < 1024px (lg)
|
|
15
|
+
*/
|
|
4
16
|
|
|
5
|
-
|
|
6
|
-
const MOBILE_BREAKPOINT = 1024
|
|
17
|
+
import { BREAKPOINTS, useMediaQuery } from './useMediaQuery';
|
|
7
18
|
|
|
8
|
-
export
|
|
9
|
-
|
|
19
|
+
export type { Breakpoint } from './useMediaQuery';
|
|
20
|
+
export { BREAKPOINTS };
|
|
10
21
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
/** True when viewport < 640px (Tailwind sm) — phones only. */
|
|
23
|
+
export function useIsPhone(): boolean {
|
|
24
|
+
return useMediaQuery(`(max-width: ${BREAKPOINTS.sm - 1}px)`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** True when viewport < 768px (Tailwind md) — phones + small tablets. */
|
|
28
|
+
export function useIsMobile(): boolean {
|
|
29
|
+
return useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`)
|
|
30
|
+
}
|
|
20
31
|
|
|
21
|
-
|
|
32
|
+
/** True when viewport < 1024px (Tailwind lg) — phones + tablets. */
|
|
33
|
+
export function useIsTabletOrBelow(): boolean {
|
|
34
|
+
return useMediaQuery(`(max-width: ${BREAKPOINTS.lg - 1}px)`)
|
|
22
35
|
}
|
|
@@ -52,6 +52,13 @@
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/* Blob - Organic drifting movement for mesh gradient orbs */
|
|
56
|
+
@keyframes blob {
|
|
57
|
+
0%, 100% { transform: translate(0, 0) scale(1); }
|
|
58
|
+
33% { transform: translate(40px, -60px) scale(1.1); }
|
|
59
|
+
66% { transform: translate(-30px, 30px) scale(0.9); }
|
|
60
|
+
}
|
|
61
|
+
|
|
55
62
|
/* Gradient Shift - Color animation with hue rotation */
|
|
56
63
|
@keyframes gradient-shift {
|
|
57
64
|
0%, 100% {
|