@tomo-inc/tomo-ui 0.0.3
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/index.html +12 -0
- package/package.json +45 -0
- package/postcss.config.cjs +6 -0
- package/project.json +7 -0
- package/src/App.tsx +23 -0
- package/src/components/button/index.tsx +2 -0
- package/src/components/card/index.tsx +2 -0
- package/src/components/chip/index.tsx +2 -0
- package/src/components/image/index.ts +2 -0
- package/src/components/index.ts +14 -0
- package/src/components/input/index.tsx +37 -0
- package/src/components/link/index.ts +2 -0
- package/src/components/mfaTypeChoose/index.tsx +114 -0
- package/src/components/modal/index.tsx +13 -0
- package/src/components/modal/modal-body.tsx +10 -0
- package/src/components/modal/modal-content.tsx +17 -0
- package/src/components/modal/modal-context.tsx +12 -0
- package/src/components/modal/modal-footer.tsx +12 -0
- package/src/components/modal/modal-header.tsx +28 -0
- package/src/components/modal/modal.tsx +152 -0
- package/src/components/modal/use-disclosure.ts +46 -0
- package/src/components/qr/index.tsx +94 -0
- package/src/components/select/index.tsx +2 -0
- package/src/components/skeleton/index.tsx +2 -0
- package/src/components/spinner/index.tsx +2 -0
- package/src/components/tabs/index.tsx +2 -0
- package/src/components/toast/index.ts +2 -0
- package/src/icons/back-icon.tsx +12 -0
- package/src/icons/close-icon.tsx +23 -0
- package/src/icons/copy-icon.tsx +22 -0
- package/src/icons/down-icon.tsx +15 -0
- package/src/icons/index.ts +13 -0
- package/src/icons/more-icon.tsx +12 -0
- package/src/icons/mydoge-icon.tsx +44 -0
- package/src/icons/ok-icon.tsx +20 -0
- package/src/icons/power-icon.tsx +15 -0
- package/src/icons/right-arrow-icon.tsx +15 -0
- package/src/icons/search-icon.tsx +19 -0
- package/src/icons/search-outlined-icon.tsx +12 -0
- package/src/icons/tomo-icon.tsx +30 -0
- package/src/icons/twitter-icon.tsx +12 -0
- package/src/index.ts +4 -0
- package/src/main.tsx +9 -0
- package/src/style.css +4 -0
- package/src/tailwind/plugin.ts +177 -0
- package/src/tailwind/tailwind.css +5 -0
- package/src/theme-provider.tsx +19 -0
- package/tsconfig.json +43 -0
- package/vite.config.ts +10 -0
package/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Tomo UI</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="./src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tomo-inc/tomo-ui",
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"module": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"import": "./src/index.ts",
|
|
12
|
+
"require": "./src/index.ts",
|
|
13
|
+
"default": "./src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"./tailwind/tailwind.css": "./src/tailwind/tailwind.css"
|
|
16
|
+
},
|
|
17
|
+
"private": false,
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@heroui/react": "2.8.5",
|
|
20
|
+
"@heroui/toast": "^2.0.17",
|
|
21
|
+
"framer-motion": "^11.5.6",
|
|
22
|
+
"next-themes": "^0.4.6",
|
|
23
|
+
"qr-code-styling": "^1.9.2"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": ">=18",
|
|
27
|
+
"react-dom": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@tailwindcss/cli": "^4.1.13",
|
|
31
|
+
"@tailwindcss/postcss": "^4.1.13",
|
|
32
|
+
"@types/react": "^18.3.3",
|
|
33
|
+
"@types/react-dom": "^18.3.0",
|
|
34
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
35
|
+
"autoprefixer": "^10.4.21",
|
|
36
|
+
"postcss": "^8.5.6",
|
|
37
|
+
"tailwindcss": "^4.1.17",
|
|
38
|
+
"tsup": "^8.3.5",
|
|
39
|
+
"typescript": "^5.7.2",
|
|
40
|
+
"vite": "^7.2.4"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"dev": "vite"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/project.json
ADDED
package/src/App.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { QRCodeComponent } from "./components";
|
|
2
|
+
import { Button } from "./components/button";
|
|
3
|
+
import { Input, NumberInput } from "./components/input";
|
|
4
|
+
import "./style.css";
|
|
5
|
+
import { TomoUIProvider } from "./theme-provider";
|
|
6
|
+
|
|
7
|
+
function App() {
|
|
8
|
+
return (
|
|
9
|
+
<TomoUIProvider defaultTheme="doge_light" forcedTheme="doge_light">
|
|
10
|
+
123123
|
|
11
|
+
<Input label="Email" type="email" />
|
|
12
|
+
<NumberInput label="Number" placeholder="Enter your number" type="number" />
|
|
13
|
+
<Button color="primary">Click me</Button>
|
|
14
|
+
<QRCodeComponent
|
|
15
|
+
content="https://tomo.inc"
|
|
16
|
+
size={280}
|
|
17
|
+
logo="https://faucet.testnet.dogeos.com/images/h4.avif"
|
|
18
|
+
logoBackground="#1C1917"
|
|
19
|
+
/>
|
|
20
|
+
</TomoUIProvider>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
export default App;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from "./button";
|
|
2
|
+
export * from "./card";
|
|
3
|
+
export * from "./chip";
|
|
4
|
+
export * from "./image";
|
|
5
|
+
export * from "./input";
|
|
6
|
+
export * from "./link";
|
|
7
|
+
export * from "./mfaTypeChoose";
|
|
8
|
+
export * from "./modal";
|
|
9
|
+
export * from "./qr";
|
|
10
|
+
export * from "./select";
|
|
11
|
+
export * from "./skeleton";
|
|
12
|
+
export * from "./spinner";
|
|
13
|
+
export * from "./tabs";
|
|
14
|
+
export * from "./toast";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import {
|
|
3
|
+
DateInput,
|
|
4
|
+
type DateInputProps,
|
|
5
|
+
Input,
|
|
6
|
+
InputOtp,
|
|
7
|
+
type InputOtpProps,
|
|
8
|
+
type InputProps,
|
|
9
|
+
NumberInput,
|
|
10
|
+
type NumberInputProps,
|
|
11
|
+
Textarea,
|
|
12
|
+
type TextAreaProps,
|
|
13
|
+
TimeInput,
|
|
14
|
+
type TimeInputProps,
|
|
15
|
+
} from "@heroui/react";
|
|
16
|
+
|
|
17
|
+
Input.displayName = "Input";
|
|
18
|
+
NumberInput.displayName = "NumberInput";
|
|
19
|
+
(DateInput as any).displayName = "DateInput";
|
|
20
|
+
Textarea.displayName = "Textarea";
|
|
21
|
+
(TimeInput as any).displayName = "TimeInput";
|
|
22
|
+
InputOtp.displayName = "InputOtp";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
DateInput,
|
|
26
|
+
Input,
|
|
27
|
+
InputOtp,
|
|
28
|
+
NumberInput,
|
|
29
|
+
Textarea,
|
|
30
|
+
TimeInput,
|
|
31
|
+
type DateInputProps,
|
|
32
|
+
type InputOtpProps,
|
|
33
|
+
type InputProps,
|
|
34
|
+
type NumberInputProps,
|
|
35
|
+
type TextAreaProps,
|
|
36
|
+
type TimeInputProps,
|
|
37
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { cn, RadioGroup, RadioProps, useRadio, VisuallyHidden } from "@heroui/react";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
|
|
4
|
+
interface CustomRadioProps extends RadioProps {
|
|
5
|
+
countdown?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const CustomRadio = ({ countdown, ...props }: CustomRadioProps) => {
|
|
9
|
+
const {
|
|
10
|
+
Component,
|
|
11
|
+
children,
|
|
12
|
+
isDisabled,
|
|
13
|
+
getBaseProps,
|
|
14
|
+
getWrapperProps,
|
|
15
|
+
getInputProps,
|
|
16
|
+
getLabelProps,
|
|
17
|
+
getLabelWrapperProps,
|
|
18
|
+
getControlProps,
|
|
19
|
+
} = useRadio(props);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Component
|
|
23
|
+
{...getBaseProps()}
|
|
24
|
+
className={cn(
|
|
25
|
+
"group inline-flex items-center hover:opacity-70 active:opacity-50 justify-between flex-row-reverse tap-highlight-transparent",
|
|
26
|
+
"max-w-full cursor-pointer border-2 border-default rounded-lg gap-4 p-4",
|
|
27
|
+
"data-[selected=true]:border-primary",
|
|
28
|
+
isDisabled && "opacity-50 cursor-not-allowed",
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
<VisuallyHidden>
|
|
32
|
+
<input {...getInputProps()} />
|
|
33
|
+
</VisuallyHidden>
|
|
34
|
+
<span {...getWrapperProps()}>
|
|
35
|
+
<span {...getControlProps()} />
|
|
36
|
+
</span>
|
|
37
|
+
<div {...getLabelWrapperProps()} className="flex items-center justify-between w-full">
|
|
38
|
+
<span {...getLabelProps()} className="text-default-600 text-base font-medium">
|
|
39
|
+
{children}
|
|
40
|
+
</span>
|
|
41
|
+
{countdown !== undefined && countdown > 0 && <CountdownTimer seconds={countdown} />}
|
|
42
|
+
</div>
|
|
43
|
+
</Component>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
export interface MfaTypeOption {
|
|
47
|
+
value: string;
|
|
48
|
+
label: string;
|
|
49
|
+
description?: string;
|
|
50
|
+
disabled?: boolean;
|
|
51
|
+
countdown?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface MfaTypeChooseProps {
|
|
55
|
+
value: string;
|
|
56
|
+
onValueChange: (value: string) => void;
|
|
57
|
+
options?: MfaTypeOption[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const defaultOptions: MfaTypeOption[] = [
|
|
61
|
+
{ value: "fido", label: "Passkey" },
|
|
62
|
+
{ value: "totp", label: "Authenticator" },
|
|
63
|
+
{ value: "emailOtp", label: "OTP Email" },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const CountdownTimer = ({ seconds }: { seconds: number }) => {
|
|
67
|
+
const [timeLeft, setTimeLeft] = useState(seconds);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
setTimeLeft(seconds);
|
|
71
|
+
}, [seconds]);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (timeLeft <= 0) return;
|
|
75
|
+
|
|
76
|
+
const timer = setInterval(() => {
|
|
77
|
+
setTimeLeft((prev) => {
|
|
78
|
+
if (prev <= 1) return 0;
|
|
79
|
+
return prev - 1;
|
|
80
|
+
});
|
|
81
|
+
}, 1000);
|
|
82
|
+
|
|
83
|
+
return () => clearInterval(timer);
|
|
84
|
+
}, [timeLeft]);
|
|
85
|
+
|
|
86
|
+
const formatTime = (totalSeconds: number) => {
|
|
87
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
88
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
89
|
+
const secs = totalSeconds % 60;
|
|
90
|
+
|
|
91
|
+
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return <span className="text-sm font-medium text-default-500">{formatTime(timeLeft)}</span>;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function MfaTypeChoose({ value, onValueChange, options = defaultOptions }: MfaTypeChooseProps) {
|
|
98
|
+
return (
|
|
99
|
+
<RadioGroup value={value} onValueChange={onValueChange} className="w-full">
|
|
100
|
+
{options.map((option) => (
|
|
101
|
+
<CustomRadio
|
|
102
|
+
key={option.value}
|
|
103
|
+
value={option.value}
|
|
104
|
+
description={option.description}
|
|
105
|
+
isDisabled={option.disabled}
|
|
106
|
+
countdown={option.countdown}
|
|
107
|
+
>
|
|
108
|
+
{option.label}
|
|
109
|
+
</CustomRadio>
|
|
110
|
+
))}
|
|
111
|
+
</RadioGroup>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
export default MfaTypeChoose;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { Modal } from "./modal";
|
|
2
|
+
export { ModalBody } from "./modal-body";
|
|
3
|
+
export { ModalContent } from "./modal-content";
|
|
4
|
+
export { ModalFooter } from "./modal-footer";
|
|
5
|
+
export { ModalHeader } from "./modal-header";
|
|
6
|
+
export { useDisclosure } from "./use-disclosure";
|
|
7
|
+
|
|
8
|
+
export type { ModalProps } from "./modal";
|
|
9
|
+
export type { ModalBodyProps } from "./modal-body";
|
|
10
|
+
export type { ModalContentProps } from "./modal-content";
|
|
11
|
+
export type { ModalFooterProps } from "./modal-footer";
|
|
12
|
+
export type { ModalHeaderProps } from "./modal-header";
|
|
13
|
+
export type { UseDisclosureProps } from "./use-disclosure";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface ModalBodyProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
className?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ModalBody({ children, className = "" }: ModalBodyProps) {
|
|
9
|
+
return <div className={`p-4 overflow-y-auto flex-1 ${className}`}>{children}</div>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { useModalContext } from "./modal-context";
|
|
3
|
+
|
|
4
|
+
export interface ModalContentProps {
|
|
5
|
+
children: ReactNode | ((onClose: () => void) => ReactNode);
|
|
6
|
+
className?: string;
|
|
7
|
+
onClose?: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ModalContent({ children, className = "", onClose }: ModalContentProps) {
|
|
11
|
+
const context = useModalContext();
|
|
12
|
+
const handleClose = onClose || context.onClose || (() => {});
|
|
13
|
+
|
|
14
|
+
const content = typeof children === "function" ? children(handleClose) : children;
|
|
15
|
+
|
|
16
|
+
return <div className={`flex flex-col ${className}`}>{content}</div>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
export interface ModalContextValue {
|
|
4
|
+
onClose?: () => void;
|
|
5
|
+
hideCloseButton?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const ModalContext = createContext<ModalContextValue>({});
|
|
9
|
+
|
|
10
|
+
export function useModalContext() {
|
|
11
|
+
return useContext(ModalContext);
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface ModalFooterProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
className?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ModalFooter({ children, className = "" }: ModalFooterProps) {
|
|
9
|
+
return (
|
|
10
|
+
<div className={`flex items-center justify-end gap-2 p-4 border-t border-default-200 ${className}`}>{children}</div>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { CloseIcon } from "../../icons";
|
|
3
|
+
import { Button } from "../button";
|
|
4
|
+
import { useModalContext } from "./modal-context";
|
|
5
|
+
|
|
6
|
+
export interface ModalHeaderProps {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
onClose?: () => void;
|
|
10
|
+
hideCloseButton?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ModalHeader({ children, className = "", onClose, hideCloseButton }: ModalHeaderProps) {
|
|
14
|
+
const context = useModalContext();
|
|
15
|
+
const handleClose = onClose || context.onClose;
|
|
16
|
+
const shouldHideCloseButton = hideCloseButton ?? context.hideCloseButton ?? false;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className={`flex items-center justify-between p-4 border-b border-default-200 ${className}`}>
|
|
20
|
+
<div className="flex-1">{children}</div>
|
|
21
|
+
{!shouldHideCloseButton && handleClose && (
|
|
22
|
+
<Button isIconOnly size="sm" variant="light" onPress={handleClose} className="ml-2" aria-label="Close">
|
|
23
|
+
<CloseIcon />
|
|
24
|
+
</Button>
|
|
25
|
+
)}
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cloneElement, isValidElement, useEffect, useRef } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
import { ModalBody } from "./modal-body";
|
|
5
|
+
import { ModalContent } from "./modal-content";
|
|
6
|
+
import { ModalContext } from "./modal-context";
|
|
7
|
+
import { ModalFooter } from "./modal-footer";
|
|
8
|
+
import { ModalHeader } from "./modal-header";
|
|
9
|
+
|
|
10
|
+
export type ModalSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "full";
|
|
11
|
+
|
|
12
|
+
export type ModalRadius = "none" | "sm" | "md" | "lg" | "full";
|
|
13
|
+
|
|
14
|
+
export type ScrollBehavior = "inside" | "outside";
|
|
15
|
+
|
|
16
|
+
export interface ModalProps {
|
|
17
|
+
isOpen?: boolean;
|
|
18
|
+
onOpenChange?: (isOpen: boolean) => void;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
size?: ModalSize;
|
|
21
|
+
radius?: ModalRadius;
|
|
22
|
+
isDismissable?: boolean;
|
|
23
|
+
isKeyboardDismissDisabled?: boolean;
|
|
24
|
+
hideCloseButton?: boolean;
|
|
25
|
+
scrollBehavior?: ScrollBehavior;
|
|
26
|
+
className?: string;
|
|
27
|
+
classNames?: {
|
|
28
|
+
base?: string;
|
|
29
|
+
backdrop?: string;
|
|
30
|
+
wrapper?: string;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const sizeClasses: Record<ModalSize, string> = {
|
|
35
|
+
xs: "max-w-xs",
|
|
36
|
+
sm: "max-w-sm",
|
|
37
|
+
md: "max-w-md",
|
|
38
|
+
lg: "max-w-lg",
|
|
39
|
+
xl: "max-w-xl",
|
|
40
|
+
"2xl": "max-w-2xl",
|
|
41
|
+
"3xl": "max-w-3xl",
|
|
42
|
+
"4xl": "max-w-4xl",
|
|
43
|
+
"5xl": "max-w-5xl",
|
|
44
|
+
full: "max-w-full",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const radiusClasses: Record<ModalRadius, string> = {
|
|
48
|
+
none: "rounded-none",
|
|
49
|
+
sm: "rounded-sm",
|
|
50
|
+
md: "rounded-md",
|
|
51
|
+
lg: "rounded-lg",
|
|
52
|
+
full: "rounded-full",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function Modal({
|
|
56
|
+
isOpen = false,
|
|
57
|
+
onOpenChange,
|
|
58
|
+
children,
|
|
59
|
+
size = "md",
|
|
60
|
+
radius = "md",
|
|
61
|
+
isDismissable = true,
|
|
62
|
+
isKeyboardDismissDisabled = false,
|
|
63
|
+
hideCloseButton = false,
|
|
64
|
+
scrollBehavior = "outside",
|
|
65
|
+
className = "",
|
|
66
|
+
classNames = {},
|
|
67
|
+
}: ModalProps) {
|
|
68
|
+
const modalRef = useRef<HTMLDivElement>(null);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!isOpen) return;
|
|
72
|
+
|
|
73
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
74
|
+
if (e.key === "Escape" && !isKeyboardDismissDisabled && isDismissable) {
|
|
75
|
+
onOpenChange?.(false);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
document.addEventListener("keydown", handleEscape);
|
|
80
|
+
document.body.style.overflow = "hidden";
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
document.removeEventListener("keydown", handleEscape);
|
|
84
|
+
document.body.style.overflow = "";
|
|
85
|
+
};
|
|
86
|
+
}, [isOpen, isKeyboardDismissDisabled, isDismissable, onOpenChange]);
|
|
87
|
+
|
|
88
|
+
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
89
|
+
if (isDismissable && e.target === e.currentTarget) {
|
|
90
|
+
onOpenChange?.(false);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (!isOpen) return null;
|
|
95
|
+
|
|
96
|
+
const sizeClass = sizeClasses[size];
|
|
97
|
+
const radiusClass = radiusClasses[radius];
|
|
98
|
+
|
|
99
|
+
const handleClose = () => {
|
|
100
|
+
onOpenChange?.(false);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const contextValue = {
|
|
104
|
+
onClose: handleClose,
|
|
105
|
+
hideCloseButton,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Clone children to pass onClose prop
|
|
109
|
+
const childrenWithProps = isValidElement(children)
|
|
110
|
+
? cloneElement(children, { onClose: handleClose } as any)
|
|
111
|
+
: children;
|
|
112
|
+
|
|
113
|
+
const modalContent = (
|
|
114
|
+
<ModalContext.Provider value={contextValue}>
|
|
115
|
+
<div
|
|
116
|
+
className={`fixed inset-0 z-50 flex items-center justify-center ${classNames.wrapper || ""}`}
|
|
117
|
+
onClick={handleBackdropClick}
|
|
118
|
+
>
|
|
119
|
+
{/* Backdrop */}
|
|
120
|
+
<div className={`fixed inset-0 bg-black/50 backdrop-blur-sm ${classNames.backdrop || ""}`} aria-hidden="true" />
|
|
121
|
+
|
|
122
|
+
{/* Modal Content */}
|
|
123
|
+
<div
|
|
124
|
+
ref={modalRef}
|
|
125
|
+
className={`
|
|
126
|
+
relative z-10 bg-background shadow-lg
|
|
127
|
+
${sizeClass}
|
|
128
|
+
${radiusClass}
|
|
129
|
+
${scrollBehavior === "inside" ? "max-h-[90vh] overflow-hidden flex flex-col" : ""}
|
|
130
|
+
${className}
|
|
131
|
+
${classNames.base || ""}
|
|
132
|
+
`}
|
|
133
|
+
onClick={(e) => e.stopPropagation()}
|
|
134
|
+
role="dialog"
|
|
135
|
+
aria-modal="true"
|
|
136
|
+
>
|
|
137
|
+
{childrenWithProps}
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</ModalContext.Provider>
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return createPortal(modalContent, document.body);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Export sub-components
|
|
147
|
+
Modal.Content = ModalContent;
|
|
148
|
+
Modal.Header = ModalHeader;
|
|
149
|
+
Modal.Body = ModalBody;
|
|
150
|
+
Modal.Footer = ModalFooter;
|
|
151
|
+
|
|
152
|
+
export { ModalBody, ModalContent, ModalFooter, ModalHeader };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface UseDisclosureProps {
|
|
4
|
+
defaultOpen?: boolean;
|
|
5
|
+
isOpen?: boolean;
|
|
6
|
+
onOpenChange?: (isOpen: boolean) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useDisclosure(props: UseDisclosureProps = {}) {
|
|
10
|
+
const { defaultOpen = false, isOpen: controlledIsOpen, onOpenChange } = props;
|
|
11
|
+
|
|
12
|
+
const [uncontrolledIsOpen, setUncontrolledIsOpen] = useState(defaultOpen);
|
|
13
|
+
|
|
14
|
+
const isOpen = controlledIsOpen !== undefined ? controlledIsOpen : uncontrolledIsOpen;
|
|
15
|
+
|
|
16
|
+
const onOpen = useCallback(() => {
|
|
17
|
+
if (controlledIsOpen === undefined) {
|
|
18
|
+
setUncontrolledIsOpen(true);
|
|
19
|
+
}
|
|
20
|
+
onOpenChange?.(true);
|
|
21
|
+
}, [controlledIsOpen, onOpenChange]);
|
|
22
|
+
|
|
23
|
+
const onClose = useCallback(() => {
|
|
24
|
+
if (controlledIsOpen === undefined) {
|
|
25
|
+
setUncontrolledIsOpen(false);
|
|
26
|
+
}
|
|
27
|
+
onOpenChange?.(false);
|
|
28
|
+
}, [controlledIsOpen, onOpenChange]);
|
|
29
|
+
|
|
30
|
+
const onOpenChangeHandler = useCallback(
|
|
31
|
+
(newIsOpen: boolean) => {
|
|
32
|
+
if (controlledIsOpen === undefined) {
|
|
33
|
+
setUncontrolledIsOpen(newIsOpen);
|
|
34
|
+
}
|
|
35
|
+
onOpenChange?.(newIsOpen);
|
|
36
|
+
},
|
|
37
|
+
[controlledIsOpen, onOpenChange],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
isOpen,
|
|
42
|
+
onOpen,
|
|
43
|
+
onClose,
|
|
44
|
+
onOpenChange: onOpenChangeHandler,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import QRCodeStyling, { type Options } from "qr-code-styling";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { Skeleton } from "../skeleton";
|
|
4
|
+
|
|
5
|
+
type QRCodeProps = {
|
|
6
|
+
content: string;
|
|
7
|
+
size?: number;
|
|
8
|
+
logo?: string;
|
|
9
|
+
logoBackground?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* QR Code Component
|
|
14
|
+
* Generates a QR code with optional logo embedded directly in the QR code
|
|
15
|
+
* Uses qr-code-styling library which handles logo embedding properly
|
|
16
|
+
*/
|
|
17
|
+
export const QRCodeComponent = ({ content, size = 168, logo, logoBackground }: QRCodeProps) => {
|
|
18
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
19
|
+
const qrCodeInstanceRef = useRef<QRCodeStyling | null>(null);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!content || !containerRef.current) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const container = containerRef.current;
|
|
27
|
+
const qrCodeOptions: Partial<Options> = {
|
|
28
|
+
width: size,
|
|
29
|
+
height: size,
|
|
30
|
+
data: content,
|
|
31
|
+
margin: 0,
|
|
32
|
+
dotsOptions: {
|
|
33
|
+
color: "#000000",
|
|
34
|
+
type: "square",
|
|
35
|
+
},
|
|
36
|
+
backgroundOptions: {
|
|
37
|
+
color: "#ffffff",
|
|
38
|
+
round: 8,
|
|
39
|
+
},
|
|
40
|
+
cornersSquareOptions: {
|
|
41
|
+
color: "#000000",
|
|
42
|
+
type: "extra-rounded",
|
|
43
|
+
},
|
|
44
|
+
cornersDotOptions: {
|
|
45
|
+
color: "#000000",
|
|
46
|
+
type: "square",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (logo) {
|
|
51
|
+
qrCodeOptions.image = logo;
|
|
52
|
+
qrCodeOptions.imageOptions = {
|
|
53
|
+
crossOrigin: "anonymous",
|
|
54
|
+
margin: 10,
|
|
55
|
+
imageSize: 0.4, // 40% of QR code size
|
|
56
|
+
hideBackgroundDots: true,
|
|
57
|
+
...(logoBackground && {
|
|
58
|
+
backgroundOptions: {
|
|
59
|
+
color: logoBackground,
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Reuse existing instance and update it, or create new one
|
|
66
|
+
if (qrCodeInstanceRef.current) {
|
|
67
|
+
// Update existing instance instead of creating new one
|
|
68
|
+
qrCodeInstanceRef.current.update(qrCodeOptions);
|
|
69
|
+
} else {
|
|
70
|
+
// Create new instance only on first render
|
|
71
|
+
const qrCode = new QRCodeStyling(qrCodeOptions);
|
|
72
|
+
qrCodeInstanceRef.current = qrCode;
|
|
73
|
+
container.innerHTML = "";
|
|
74
|
+
qrCode.append(container);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Cleanup on unmount
|
|
78
|
+
const cleanupContainer = container;
|
|
79
|
+
return () => {
|
|
80
|
+
if (cleanupContainer && qrCodeInstanceRef.current) {
|
|
81
|
+
cleanupContainer.innerHTML = "";
|
|
82
|
+
qrCodeInstanceRef.current = null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}, [content, size, logo, logoBackground]);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<Skeleton isLoaded={!!content} style={{ width: size, height: size }} className="rounded-lg">
|
|
89
|
+
<div className="flex items-center justify-center p-2 rounded-lg">
|
|
90
|
+
<div ref={containerRef} />
|
|
91
|
+
</div>
|
|
92
|
+
</Skeleton>
|
|
93
|
+
);
|
|
94
|
+
};
|