@sanvika/ui 0.1.5 → 0.3.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/dist/index.js +129 -101
- package/package.json +15 -29
- package/src/components/buttons/Button.jsx +97 -0
- package/src/components/buttons/Button.stories.jsx +110 -0
- package/src/components/buttons/FavoriteHeartButton.jsx +41 -0
- package/src/components/buttons/FavoriteHeartButton.stories.jsx +48 -0
- package/src/components/buttons/ScrollButton.jsx +36 -0
- package/src/components/buttons/ScrollButton.stories.jsx +21 -0
- package/src/components/buttons/ThreeDotButton.jsx +36 -0
- package/src/components/buttons/ThreeDotButton.stories.jsx +14 -0
- package/src/components/common/Container.jsx +38 -0
- package/src/components/common/Section.jsx +60 -0
- package/src/components/common/Section.stories.jsx +64 -0
- package/src/components/icons/BellIcon.jsx +10 -0
- package/src/components/icons/ChevronDown.jsx +20 -0
- package/src/components/icons/EmailIcon.jsx +9 -0
- package/src/components/icons/FaAngleDown.jsx +9 -0
- package/src/components/icons/FaAngleRight.jsx +9 -0
- package/src/components/icons/FaArrowDown.jsx +9 -0
- package/src/components/icons/FaArrowLeft.jsx +9 -0
- package/src/components/icons/FaArrowUp.jsx +9 -0
- package/src/components/icons/FaBell.jsx +9 -0
- package/src/components/icons/FaBullhorn.jsx +10 -0
- package/src/components/icons/FaCalendarAlt.jsx +9 -0
- package/src/components/icons/FaCheck.jsx +9 -0
- package/src/components/icons/FaCheckCircle.jsx +9 -0
- package/src/components/icons/FaCheckDouble.jsx +9 -0
- package/src/components/icons/FaChevronDown.jsx +9 -0
- package/src/components/icons/FaChevronRight.jsx +9 -0
- package/src/components/icons/FaCircle.jsx +9 -0
- package/src/components/icons/FaComments.jsx +9 -0
- package/src/components/icons/FaEye.jsx +9 -0
- package/src/components/icons/FaEyeSlash.jsx +9 -0
- package/src/components/icons/FaHome.jsx +9 -0
- package/src/components/icons/FaKeyboard.jsx +9 -0
- package/src/components/icons/FaLocationArrow.jsx +9 -0
- package/src/components/icons/FaMapMarkerAlt.jsx +9 -0
- package/src/components/icons/FaMoon.jsx +9 -0
- package/src/components/icons/FaPaperPlane.jsx +9 -0
- package/src/components/icons/FaPaperclip.jsx +9 -0
- package/src/components/icons/FaSignInAlt.jsx +9 -0
- package/src/components/icons/FaSmile.jsx +9 -0
- package/src/components/icons/FaStar.jsx +9 -0
- package/src/components/icons/FaTag.jsx +9 -0
- package/src/components/icons/FaThumbsDown.jsx +9 -0
- package/src/components/icons/FaThumbsUp.jsx +9 -0
- package/src/components/icons/FaTrash.jsx +9 -0
- package/src/components/icons/FaUser.jsx +9 -0
- package/src/components/icons/FaUserCircle.jsx +9 -0
- package/src/components/icons/FacebookIcon.jsx +9 -0
- package/src/components/icons/HalfMoonIcon.jsx +18 -0
- package/src/components/icons/HeartIcon.jsx +9 -0
- package/src/components/icons/InstagramIcon.jsx +9 -0
- package/src/components/icons/LinkedInIcon.jsx +9 -0
- package/src/components/icons/MapMarkerIcon.jsx +9 -0
- package/src/components/icons/MdWbSunny.jsx +9 -0
- package/src/components/icons/ReFreshIcon.jsx +49 -0
- package/src/components/icons/SearchIcon.jsx +20 -0
- package/src/components/icons/StarIcon.jsx +9 -0
- package/src/components/icons/TelegramIcon.jsx +9 -0
- package/src/components/icons/TwitterIcon.jsx +9 -0
- package/src/components/icons/UserIcon.jsx +9 -0
- package/src/components/icons/WhatsappIcon.jsx +9 -0
- package/src/components/icons/YoutubeIcon.jsx +9 -0
- package/src/components/icons/index.js +60 -0
- package/src/components/layout/Footer.jsx +53 -0
- package/src/components/layout/Footer.stories.jsx +28 -0
- package/src/components/layout/Navbar.jsx +50 -0
- package/src/components/layout/Navbar.stories.jsx +42 -0
- package/src/components/layout/SubNavbar.jsx +36 -0
- package/src/components/modals/Modal.jsx +74 -0
- package/src/components/modals/Modal.stories.jsx +93 -0
- package/src/components/progressBar/ProgressBar.jsx +113 -0
- package/src/components/progressBar/ProgressBar.stories.jsx +67 -0
- package/src/context/ThemeContext.jsx +100 -0
- package/src/index.js +33 -0
- package/src/layouts/Layout.jsx +24 -0
- package/src/server/index.js +173 -0
- package/README.md +0 -36
- package/dist/EmailIcon-DumDw7u2.js +0 -781
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// src/components/layout/Footer.jsx
|
|
2
|
+
"use client";
|
|
3
|
+
import styles from "../../styles/components/layouts/Footer.module.css";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Footer — props-driven layout shell for all 50+ Sanvika projects.
|
|
7
|
+
* 3-row layout: socialSlot | legalSlot | trustSlot.
|
|
8
|
+
* Each project passes its own social icons, legal links, trust badge via slots.
|
|
9
|
+
*
|
|
10
|
+
* @param {React.ReactNode} socialSlot - Social icons row (+ optional extra actions like RefreshButton)
|
|
11
|
+
* @param {React.ReactNode} legalSlot - Legal links row (About, Contact, Privacy, Terms, etc.)
|
|
12
|
+
* @param {React.ReactNode} trustSlot - Trust badge row (DUNS badge, copyright, etc.)
|
|
13
|
+
* @param {string} [className] - Additional CSS class
|
|
14
|
+
*/
|
|
15
|
+
const Footer = ({
|
|
16
|
+
socialSlot,
|
|
17
|
+
legalSlot,
|
|
18
|
+
trustSlot,
|
|
19
|
+
className = "",
|
|
20
|
+
}) => {
|
|
21
|
+
return (
|
|
22
|
+
<footer className={`${styles.footer} ${className}`.trim()}>
|
|
23
|
+
<div className={styles.footerContent}>
|
|
24
|
+
{/* Row 1: Social icons + extra actions */}
|
|
25
|
+
{socialSlot && (
|
|
26
|
+
<div className={styles.topLine}>
|
|
27
|
+
<div className={styles.socialIconsContainer}>
|
|
28
|
+
<div className={styles.socialIcons}>
|
|
29
|
+
{socialSlot}
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
)}
|
|
34
|
+
|
|
35
|
+
{/* Row 2: Legal links */}
|
|
36
|
+
{legalSlot && (
|
|
37
|
+
<div className={styles.legalLinks}>
|
|
38
|
+
{legalSlot}
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
41
|
+
|
|
42
|
+
{/* Row 3: Trust badge */}
|
|
43
|
+
{trustSlot && (
|
|
44
|
+
<div className={styles.dunsContainer}>
|
|
45
|
+
{trustSlot}
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
</footer>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export default Footer;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/components/layout/Footer.stories.jsx
|
|
2
|
+
import Footer from "./Footer";
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "Layout/Footer",
|
|
6
|
+
component: Footer,
|
|
7
|
+
parameters: { layout: "fullscreen" },
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default meta;
|
|
11
|
+
|
|
12
|
+
export const Default = {};
|
|
13
|
+
|
|
14
|
+
export const CustomLinks = {
|
|
15
|
+
args: {
|
|
16
|
+
legalLinks: [
|
|
17
|
+
{ href: "/about", label: "About" },
|
|
18
|
+
{ href: "/privacy", label: "Privacy" },
|
|
19
|
+
{ href: "/terms", label: "Terms" },
|
|
20
|
+
],
|
|
21
|
+
trustText: "D-U-N-S® Verified · My Company",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const NoSocialLinks = {
|
|
26
|
+
args: { socialLinks: [] },
|
|
27
|
+
};
|
|
28
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/components/layout/Navbar.jsx
|
|
2
|
+
"use client";
|
|
3
|
+
import styles from "../../styles/components/layouts/Navbar.module.css";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Navbar — props-driven layout shell for all 50+ Sanvika projects.
|
|
7
|
+
* 3-column grid: leftSlot | centerSlot | rightSlot.
|
|
8
|
+
* Each project passes its own logo, buttons, menus via slot props.
|
|
9
|
+
*
|
|
10
|
+
* @param {React.ReactNode} leftSlot - Logo area (left column)
|
|
11
|
+
* @param {React.ReactNode} centerSlot - Center menu items wrapped in <ul>
|
|
12
|
+
* @param {React.ReactNode} rightSlot - Right actions (profile, settings)
|
|
13
|
+
* @param {string} [className] - Additional CSS class
|
|
14
|
+
* @param {string} [ariaLabel] - Accessible label for <nav>
|
|
15
|
+
*/
|
|
16
|
+
const Navbar = ({
|
|
17
|
+
leftSlot,
|
|
18
|
+
centerSlot,
|
|
19
|
+
rightSlot,
|
|
20
|
+
className = "",
|
|
21
|
+
ariaLabel = "Main navigation",
|
|
22
|
+
}) => {
|
|
23
|
+
return (
|
|
24
|
+
<nav
|
|
25
|
+
className={`${styles.navbar} ${className}`.trim()}
|
|
26
|
+
aria-label={ariaLabel}
|
|
27
|
+
>
|
|
28
|
+
{/* Left: Logo area */}
|
|
29
|
+
<div className={styles.navSectionLeft}>
|
|
30
|
+
{leftSlot}
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
{/* Center: Menu items */}
|
|
34
|
+
<div className={styles.navSectionCenter}>
|
|
35
|
+
{centerSlot && (
|
|
36
|
+
<ul className={styles.menu}>
|
|
37
|
+
{centerSlot}
|
|
38
|
+
</ul>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
{/* Right: Action buttons */}
|
|
43
|
+
<div className={styles.navSectionRight}>
|
|
44
|
+
{rightSlot}
|
|
45
|
+
</div>
|
|
46
|
+
</nav>
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export default Navbar;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// src/components/layout/Navbar.stories.jsx
|
|
2
|
+
import Navbar from "./Navbar";
|
|
3
|
+
|
|
4
|
+
const NAV_LINKS = [
|
|
5
|
+
{ href: "/", label: "Home" },
|
|
6
|
+
{ href: "/about", label: "About" },
|
|
7
|
+
{ href: "/listings", label: "Listings" },
|
|
8
|
+
{ href: "/contact", label: "Contact" },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const meta = {
|
|
12
|
+
title: "Layout/Navbar",
|
|
13
|
+
component: Navbar,
|
|
14
|
+
parameters: { layout: "fullscreen" },
|
|
15
|
+
argTypes: {
|
|
16
|
+
logoText: { control: "text" },
|
|
17
|
+
activePath: { control: "text" },
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
|
|
23
|
+
export const Default = {
|
|
24
|
+
args: { navLinks: NAV_LINKS, activePath: "/", logoText: "Sanvika" },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const ActiveAbout = {
|
|
28
|
+
args: { navLinks: NAV_LINKS, activePath: "/about", logoText: "Sanvika" },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const CustomLogo = {
|
|
32
|
+
args: { navLinks: NAV_LINKS, activePath: "/", logoText: "MyApp" },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const FewLinks = {
|
|
36
|
+
args: {
|
|
37
|
+
navLinks: [{ href: "/", label: "Home" }, { href: "/contact", label: "Contact" }],
|
|
38
|
+
activePath: "/",
|
|
39
|
+
logoText: "Sanvika",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/components/layout/SubNavbar.jsx
|
|
2
|
+
"use client";
|
|
3
|
+
import styles from "../../styles/components/layouts/SubNavbar.module.css";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SubNavbar — fixed below Navbar, props-driven layout shell.
|
|
7
|
+
* Accepts items as children (React nodes) and optional afterContent
|
|
8
|
+
* for global modals/overlays that should render outside the nav.
|
|
9
|
+
*
|
|
10
|
+
* @param {React.ReactNode} children - Menu items (each wrapped in <li>)
|
|
11
|
+
* @param {React.ReactNode} [afterContent] - Content rendered after nav (modals, overlays)
|
|
12
|
+
* @param {string} [className] - Additional CSS class
|
|
13
|
+
* @param {string} [ariaLabel] - Accessible label for <nav>
|
|
14
|
+
*/
|
|
15
|
+
const SubNavbar = ({
|
|
16
|
+
children,
|
|
17
|
+
afterContent,
|
|
18
|
+
className = "",
|
|
19
|
+
ariaLabel = "Secondary navigation",
|
|
20
|
+
}) => {
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<nav
|
|
24
|
+
className={`${styles.subNavbar} ${className}`.trim()}
|
|
25
|
+
aria-label={ariaLabel}
|
|
26
|
+
>
|
|
27
|
+
<ul className={styles.menu}>
|
|
28
|
+
{children}
|
|
29
|
+
</ul>
|
|
30
|
+
</nav>
|
|
31
|
+
{afterContent}
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default SubNavbar;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// src/components/modals/Modal.jsx
|
|
3
|
+
import React, { useEffect, useSyncExternalStore } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import styles from "../../styles/components/modals/Modal.module.css";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Modal — pure props-driven portal modal.
|
|
9
|
+
* Close button is NOT included — pass it in via children.
|
|
10
|
+
*
|
|
11
|
+
* @param {boolean} isOpen - controls visibility
|
|
12
|
+
* @param {function} onClose - called on backdrop click or ESC key
|
|
13
|
+
* @param {React.ReactNode} children - modal content (include your own close button here)
|
|
14
|
+
* @param {string} [className]
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// SSR-safe mount detection using useSyncExternalStore (React 18+ recommended)
|
|
18
|
+
const emptySubscribe = () => () => {};
|
|
19
|
+
const useIsMounted = () =>
|
|
20
|
+
useSyncExternalStore(emptySubscribe, () => true, () => false);
|
|
21
|
+
|
|
22
|
+
const Modal = ({ isOpen, onClose, children, className }) => {
|
|
23
|
+
const mounted = useIsMounted();
|
|
24
|
+
|
|
25
|
+
// Lock body scroll when modal is open
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (isOpen) {
|
|
28
|
+
document.body.style.overflow = "hidden";
|
|
29
|
+
document.body.style.position = "fixed";
|
|
30
|
+
document.body.style.width = "100%";
|
|
31
|
+
} else {
|
|
32
|
+
document.body.style.overflow = "";
|
|
33
|
+
document.body.style.position = "";
|
|
34
|
+
document.body.style.width = "";
|
|
35
|
+
}
|
|
36
|
+
return () => {
|
|
37
|
+
document.body.style.overflow = "";
|
|
38
|
+
document.body.style.position = "";
|
|
39
|
+
document.body.style.width = "";
|
|
40
|
+
};
|
|
41
|
+
}, [isOpen]);
|
|
42
|
+
|
|
43
|
+
// ESC key closes modal
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const handleEsc = (e) => {
|
|
46
|
+
if (e.key === "Escape" && isOpen) onClose();
|
|
47
|
+
};
|
|
48
|
+
if (isOpen) document.addEventListener("keydown", handleEsc);
|
|
49
|
+
return () => document.removeEventListener("keydown", handleEsc);
|
|
50
|
+
}, [isOpen, onClose]);
|
|
51
|
+
|
|
52
|
+
if (!isOpen || !mounted) return null;
|
|
53
|
+
|
|
54
|
+
const handleBackdropClick = (e) => {
|
|
55
|
+
if (e.target === e.currentTarget) onClose();
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return createPortal(
|
|
59
|
+
<div
|
|
60
|
+
className={styles.modalOverlay}
|
|
61
|
+
onClick={handleBackdropClick}
|
|
62
|
+
role="dialog"
|
|
63
|
+
aria-modal="true"
|
|
64
|
+
>
|
|
65
|
+
<div className={`${styles.modalContent} ${className ?? ""}`}>
|
|
66
|
+
{children}
|
|
67
|
+
</div>
|
|
68
|
+
</div>,
|
|
69
|
+
document.body
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export default Modal;
|
|
74
|
+
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// src/components/modals/Modal.stories.jsx
|
|
2
|
+
"use client";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import Modal from "./Modal";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Components/Modal",
|
|
8
|
+
component: Modal,
|
|
9
|
+
parameters: { layout: "centered" },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
|
|
14
|
+
// Interactive wrapper since Modal needs open/close state
|
|
15
|
+
const ModalDemo = ({ buttonLabel = "Open Modal", children }) => {
|
|
16
|
+
const [open, setOpen] = useState(false);
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<button
|
|
20
|
+
onClick={() => setOpen(true)}
|
|
21
|
+
style={{
|
|
22
|
+
padding: "8px 20px",
|
|
23
|
+
background: "var(--primary-color, #0984e3)",
|
|
24
|
+
color: "#fff",
|
|
25
|
+
border: "none",
|
|
26
|
+
borderRadius: "6px",
|
|
27
|
+
cursor: "pointer",
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
{buttonLabel}
|
|
31
|
+
</button>
|
|
32
|
+
<Modal isOpen={open} onClose={() => setOpen(false)}>
|
|
33
|
+
{children}
|
|
34
|
+
<button
|
|
35
|
+
onClick={() => setOpen(false)}
|
|
36
|
+
style={{
|
|
37
|
+
position: "absolute",
|
|
38
|
+
top: 12,
|
|
39
|
+
right: 12,
|
|
40
|
+
background: "transparent",
|
|
41
|
+
border: "none",
|
|
42
|
+
fontSize: 20,
|
|
43
|
+
cursor: "pointer",
|
|
44
|
+
color: "var(--secondary-text-color)",
|
|
45
|
+
}}
|
|
46
|
+
aria-label="Close modal"
|
|
47
|
+
>
|
|
48
|
+
✖
|
|
49
|
+
</button>
|
|
50
|
+
</Modal>
|
|
51
|
+
</>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const Default = {
|
|
56
|
+
render: () => (
|
|
57
|
+
<ModalDemo buttonLabel="Open Modal">
|
|
58
|
+
<h2 style={{ margin: "0 0 12px", color: "var(--heading-color)" }}>Modal Title</h2>
|
|
59
|
+
<p style={{ color: "var(--text-color)" }}>
|
|
60
|
+
This is modal content. Click the ✖ button or press ESC to close. Clicking outside also closes.
|
|
61
|
+
</p>
|
|
62
|
+
</ModalDemo>
|
|
63
|
+
),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const WithForm = {
|
|
67
|
+
render: () => (
|
|
68
|
+
<ModalDemo buttonLabel="Open Form Modal">
|
|
69
|
+
<h2 style={{ margin: "0 0 16px", color: "var(--heading-color)" }}>Confirm Action</h2>
|
|
70
|
+
<p style={{ marginBottom: 20, color: "var(--secondary-text-color)" }}>
|
|
71
|
+
Are you sure you want to delete this item?
|
|
72
|
+
</p>
|
|
73
|
+
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
|
74
|
+
<button
|
|
75
|
+
style={{ padding: "8px 16px", background: "var(--secondary-color)", color: "#fff", border: "none", borderRadius: 6, cursor: "pointer" }}
|
|
76
|
+
>
|
|
77
|
+
Cancel
|
|
78
|
+
</button>
|
|
79
|
+
<button
|
|
80
|
+
style={{ padding: "8px 16px", background: "var(--error-color, #dc3545)", color: "#fff", border: "none", borderRadius: 6, cursor: "pointer" }}
|
|
81
|
+
>
|
|
82
|
+
Delete
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
<button
|
|
86
|
+
style={{ position: "absolute", top: 12, right: 12, background: "transparent", border: "none", fontSize: 20, cursor: "pointer", color: "var(--secondary-text-color)" }}
|
|
87
|
+
aria-label="Close"
|
|
88
|
+
>
|
|
89
|
+
✖
|
|
90
|
+
</button>
|
|
91
|
+
</ModalDemo>
|
|
92
|
+
),
|
|
93
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// src/components/progressBar/ProgressBar.jsx
|
|
3
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
4
|
+
import styles from "../../styles/components/progressBar/ProgressBar.module.css";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ProgressBar — animated, pure props-driven progress indicator.
|
|
8
|
+
*
|
|
9
|
+
* @param {number} [progress=0] - 0 to 100
|
|
10
|
+
* @param {number} [height=6] - bar height in px
|
|
11
|
+
* @param {string} [color] - CSS color or var() for the filled bar
|
|
12
|
+
* @param {string} [backgroundColor] - CSS color or var() for the track
|
|
13
|
+
* @param {boolean} [animate=true] - smooth animation
|
|
14
|
+
* @param {number} [speed=300] - animation base duration in ms
|
|
15
|
+
* @param {boolean} [showLabel=false] - show percentage label
|
|
16
|
+
* @param {"top"|"bottom"} [labelPosition="top"]
|
|
17
|
+
* @param {function} [onComplete] - called when progress reaches 100
|
|
18
|
+
* @param {string} [className]
|
|
19
|
+
* @param {object} [style]
|
|
20
|
+
*/
|
|
21
|
+
const ProgressBar = ({
|
|
22
|
+
progress = 0,
|
|
23
|
+
height = 6,
|
|
24
|
+
color = "var(--primary-color, #007bff)",
|
|
25
|
+
backgroundColor = "var(--progress-bg, rgba(0, 0, 0, 0.1))",
|
|
26
|
+
animate = true,
|
|
27
|
+
speed = 300,
|
|
28
|
+
showLabel = false,
|
|
29
|
+
labelPosition = "top",
|
|
30
|
+
onComplete = null,
|
|
31
|
+
className = "",
|
|
32
|
+
style = {},
|
|
33
|
+
}) => {
|
|
34
|
+
const [animatedProgress, setAnimatedProgress] = useState(0);
|
|
35
|
+
const rafIdRef = useRef(null);
|
|
36
|
+
const startRef = useRef(0);
|
|
37
|
+
const endRef = useRef(progress);
|
|
38
|
+
const startTimeRef = useRef(null);
|
|
39
|
+
const completedRef = useRef(false);
|
|
40
|
+
|
|
41
|
+
const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!animate) return;
|
|
45
|
+
startRef.current = animatedProgress;
|
|
46
|
+
}, [animatedProgress, animate]);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!animate) return; // non-animated: use progress directly via clampedProgress below
|
|
50
|
+
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current);
|
|
51
|
+
endRef.current = progress;
|
|
52
|
+
startTimeRef.current = null;
|
|
53
|
+
if (startRef.current === endRef.current) return;
|
|
54
|
+
|
|
55
|
+
const delta = Math.abs(endRef.current - startRef.current);
|
|
56
|
+
const duration = Math.max(speed, Math.min(1500, 20 * delta));
|
|
57
|
+
|
|
58
|
+
const step = (ts) => {
|
|
59
|
+
if (startTimeRef.current === null) startTimeRef.current = ts;
|
|
60
|
+
const elapsed = ts - startTimeRef.current;
|
|
61
|
+
const t = Math.max(0, Math.min(1, elapsed / duration));
|
|
62
|
+
const eased = easeOutCubic(t);
|
|
63
|
+
const next = startRef.current + (endRef.current - startRef.current) * eased;
|
|
64
|
+
setAnimatedProgress(next);
|
|
65
|
+
if (t < 1) {
|
|
66
|
+
rafIdRef.current = requestAnimationFrame(step);
|
|
67
|
+
} else {
|
|
68
|
+
setAnimatedProgress(endRef.current);
|
|
69
|
+
if (endRef.current >= 100 && onComplete && !completedRef.current) {
|
|
70
|
+
completedRef.current = true;
|
|
71
|
+
onComplete();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
rafIdRef.current = requestAnimationFrame(step);
|
|
76
|
+
return () => {
|
|
77
|
+
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current);
|
|
78
|
+
};
|
|
79
|
+
}, [progress, animate, speed, onComplete]);
|
|
80
|
+
|
|
81
|
+
// When not animated, use progress directly; when animated, use animatedProgress
|
|
82
|
+
const clampedProgress = Math.max(0, Math.min(100, animate ? animatedProgress : progress));
|
|
83
|
+
const label = `${Math.round(clampedProgress)}%`;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div
|
|
87
|
+
className={`${styles.wrapper} ${className}`}
|
|
88
|
+
style={style}
|
|
89
|
+
role="progressbar"
|
|
90
|
+
aria-valuenow={Math.round(clampedProgress)}
|
|
91
|
+
aria-valuemin={0}
|
|
92
|
+
aria-valuemax={100}
|
|
93
|
+
>
|
|
94
|
+
{showLabel && labelPosition === "top" && (
|
|
95
|
+
<span className={styles.label}>{label}</span>
|
|
96
|
+
)}
|
|
97
|
+
<div
|
|
98
|
+
className={styles.track}
|
|
99
|
+
style={{ height, backgroundColor }}
|
|
100
|
+
>
|
|
101
|
+
<div
|
|
102
|
+
className={styles.bar}
|
|
103
|
+
style={{ width: `${clampedProgress}%`, background: color }}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
{showLabel && labelPosition === "bottom" && (
|
|
107
|
+
<span className={styles.label}>{label}</span>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export default ProgressBar;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/components/progressBar/ProgressBar.stories.jsx
|
|
2
|
+
"use client";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import ProgressBar from "./ProgressBar";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Components/ProgressBar",
|
|
8
|
+
component: ProgressBar,
|
|
9
|
+
parameters: { layout: "padded" },
|
|
10
|
+
argTypes: {
|
|
11
|
+
progress: { control: { type: "range", min: 0, max: 100, step: 1 } },
|
|
12
|
+
height: { control: { type: "range", min: 2, max: 24, step: 1 } },
|
|
13
|
+
animate: { control: "boolean" },
|
|
14
|
+
showLabel: { control: "boolean" },
|
|
15
|
+
labelPosition: { control: "select", options: ["top", "bottom"] },
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
|
|
21
|
+
export const Default = {
|
|
22
|
+
args: { progress: 60, height: 6, animate: true },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const WithLabel = {
|
|
26
|
+
args: { progress: 75, height: 8, showLabel: true, labelPosition: "top" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const Thick = {
|
|
30
|
+
args: { progress: 45, height: 16, showLabel: true },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const Complete = {
|
|
34
|
+
args: { progress: 100, height: 6, showLabel: true },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const NoAnimation = {
|
|
38
|
+
args: { progress: 50, animate: false, height: 6 },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Interactive story with increment button
|
|
42
|
+
const ProgressBarInteractive = () => {
|
|
43
|
+
const [val, setVal] = useState(0);
|
|
44
|
+
return (
|
|
45
|
+
<div style={{ width: 360, display: "flex", flexDirection: "column", gap: 16 }}>
|
|
46
|
+
<ProgressBar progress={val} height={8} showLabel animate />
|
|
47
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
48
|
+
<button
|
|
49
|
+
onClick={() => setVal((v) => Math.min(100, v + 10))}
|
|
50
|
+
style={{ padding: "6px 16px", background: "var(--primary-color, #007bff)", color: "#fff", border: "none", borderRadius: 6, cursor: "pointer" }}
|
|
51
|
+
>
|
|
52
|
+
+10
|
|
53
|
+
</button>
|
|
54
|
+
<button
|
|
55
|
+
onClick={() => setVal(0)}
|
|
56
|
+
style={{ padding: "6px 16px", background: "var(--secondary-color, #6c757d)", color: "#fff", border: "none", borderRadius: 6, cursor: "pointer" }}
|
|
57
|
+
>
|
|
58
|
+
Reset
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const Interactive = {
|
|
66
|
+
render: () => <ProgressBarInteractive />,
|
|
67
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// src/context/ThemeContext.jsx
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useState,
|
|
8
|
+
useCallback,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
const ThemeContext = createContext();
|
|
12
|
+
|
|
13
|
+
export const ThemeProvider = ({ children }) => {
|
|
14
|
+
const [mounted, setMounted] = useState(false);
|
|
15
|
+
const [theme, setTheme] = useState("light"); // SSR fallback
|
|
16
|
+
|
|
17
|
+
// Step 1: On mount, load theme from localStorage. Default = dark.
|
|
18
|
+
// Defer state updates so this effect is not classified as synchronous setState-in-effect (react-hooks/set-state-in-effect).
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
let cancelled = false;
|
|
21
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
22
|
+
const onSystemChange = (e) => {
|
|
23
|
+
if (!localStorage.getItem("theme")) {
|
|
24
|
+
const sys = e.matches ? "dark" : "light";
|
|
25
|
+
setTheme(sys);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
queueMicrotask(() => {
|
|
30
|
+
if (cancelled) return;
|
|
31
|
+
try {
|
|
32
|
+
const saved = localStorage.getItem("theme");
|
|
33
|
+
const resolved = saved ?? "dark"; // First visit = dark
|
|
34
|
+
setTheme(resolved);
|
|
35
|
+
document.documentElement.setAttribute("data-theme", resolved);
|
|
36
|
+
mq.addEventListener("change", onSystemChange);
|
|
37
|
+
setMounted(true);
|
|
38
|
+
} catch {
|
|
39
|
+
if (cancelled) return;
|
|
40
|
+
setTheme("dark");
|
|
41
|
+
document.documentElement.setAttribute("data-theme", "dark");
|
|
42
|
+
setMounted(true);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return () => {
|
|
47
|
+
cancelled = true;
|
|
48
|
+
mq.removeEventListener("change", onSystemChange);
|
|
49
|
+
};
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
// Step 2: Apply theme with smooth transition class
|
|
53
|
+
const applyTheme = useCallback((next) => {
|
|
54
|
+
const root = document.documentElement;
|
|
55
|
+
root.classList.add("theme-transition");
|
|
56
|
+
root.setAttribute("data-theme", next);
|
|
57
|
+
localStorage.setItem("theme", next);
|
|
58
|
+
const t = setTimeout(() => root.classList.remove("theme-transition"), 300);
|
|
59
|
+
return () => clearTimeout(t);
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
// Step 3: Apply whenever theme state changes (after mount)
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (mounted) applyTheme(theme);
|
|
65
|
+
}, [theme, mounted, applyTheme]);
|
|
66
|
+
|
|
67
|
+
// Step 4: Toggle
|
|
68
|
+
const toggleTheme = useCallback(() => {
|
|
69
|
+
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
// Step 5: Explicit setter (for admin/testing)
|
|
73
|
+
const setThemeExplicitly = useCallback((next) => {
|
|
74
|
+
if (next !== "light" && next !== "dark") return;
|
|
75
|
+
setTheme(next);
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
// React 19 direct context syntax
|
|
79
|
+
return (
|
|
80
|
+
<ThemeContext
|
|
81
|
+
value={{
|
|
82
|
+
theme,
|
|
83
|
+
toggleTheme,
|
|
84
|
+
setTheme: setThemeExplicitly,
|
|
85
|
+
isDarkMode: theme === "dark",
|
|
86
|
+
isThemeReady: mounted,
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
{children}
|
|
90
|
+
</ThemeContext>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const useTheme = () => {
|
|
95
|
+
const ctx = useContext(ThemeContext);
|
|
96
|
+
if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
|
|
97
|
+
return ctx;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export { ThemeContext };
|