@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.
Files changed (80) hide show
  1. package/dist/index.js +129 -101
  2. package/package.json +15 -29
  3. package/src/components/buttons/Button.jsx +97 -0
  4. package/src/components/buttons/Button.stories.jsx +110 -0
  5. package/src/components/buttons/FavoriteHeartButton.jsx +41 -0
  6. package/src/components/buttons/FavoriteHeartButton.stories.jsx +48 -0
  7. package/src/components/buttons/ScrollButton.jsx +36 -0
  8. package/src/components/buttons/ScrollButton.stories.jsx +21 -0
  9. package/src/components/buttons/ThreeDotButton.jsx +36 -0
  10. package/src/components/buttons/ThreeDotButton.stories.jsx +14 -0
  11. package/src/components/common/Container.jsx +38 -0
  12. package/src/components/common/Section.jsx +60 -0
  13. package/src/components/common/Section.stories.jsx +64 -0
  14. package/src/components/icons/BellIcon.jsx +10 -0
  15. package/src/components/icons/ChevronDown.jsx +20 -0
  16. package/src/components/icons/EmailIcon.jsx +9 -0
  17. package/src/components/icons/FaAngleDown.jsx +9 -0
  18. package/src/components/icons/FaAngleRight.jsx +9 -0
  19. package/src/components/icons/FaArrowDown.jsx +9 -0
  20. package/src/components/icons/FaArrowLeft.jsx +9 -0
  21. package/src/components/icons/FaArrowUp.jsx +9 -0
  22. package/src/components/icons/FaBell.jsx +9 -0
  23. package/src/components/icons/FaBullhorn.jsx +10 -0
  24. package/src/components/icons/FaCalendarAlt.jsx +9 -0
  25. package/src/components/icons/FaCheck.jsx +9 -0
  26. package/src/components/icons/FaCheckCircle.jsx +9 -0
  27. package/src/components/icons/FaCheckDouble.jsx +9 -0
  28. package/src/components/icons/FaChevronDown.jsx +9 -0
  29. package/src/components/icons/FaChevronRight.jsx +9 -0
  30. package/src/components/icons/FaCircle.jsx +9 -0
  31. package/src/components/icons/FaComments.jsx +9 -0
  32. package/src/components/icons/FaEye.jsx +9 -0
  33. package/src/components/icons/FaEyeSlash.jsx +9 -0
  34. package/src/components/icons/FaHome.jsx +9 -0
  35. package/src/components/icons/FaKeyboard.jsx +9 -0
  36. package/src/components/icons/FaLocationArrow.jsx +9 -0
  37. package/src/components/icons/FaMapMarkerAlt.jsx +9 -0
  38. package/src/components/icons/FaMoon.jsx +9 -0
  39. package/src/components/icons/FaPaperPlane.jsx +9 -0
  40. package/src/components/icons/FaPaperclip.jsx +9 -0
  41. package/src/components/icons/FaSignInAlt.jsx +9 -0
  42. package/src/components/icons/FaSmile.jsx +9 -0
  43. package/src/components/icons/FaStar.jsx +9 -0
  44. package/src/components/icons/FaTag.jsx +9 -0
  45. package/src/components/icons/FaThumbsDown.jsx +9 -0
  46. package/src/components/icons/FaThumbsUp.jsx +9 -0
  47. package/src/components/icons/FaTrash.jsx +9 -0
  48. package/src/components/icons/FaUser.jsx +9 -0
  49. package/src/components/icons/FaUserCircle.jsx +9 -0
  50. package/src/components/icons/FacebookIcon.jsx +9 -0
  51. package/src/components/icons/HalfMoonIcon.jsx +18 -0
  52. package/src/components/icons/HeartIcon.jsx +9 -0
  53. package/src/components/icons/InstagramIcon.jsx +9 -0
  54. package/src/components/icons/LinkedInIcon.jsx +9 -0
  55. package/src/components/icons/MapMarkerIcon.jsx +9 -0
  56. package/src/components/icons/MdWbSunny.jsx +9 -0
  57. package/src/components/icons/ReFreshIcon.jsx +49 -0
  58. package/src/components/icons/SearchIcon.jsx +20 -0
  59. package/src/components/icons/StarIcon.jsx +9 -0
  60. package/src/components/icons/TelegramIcon.jsx +9 -0
  61. package/src/components/icons/TwitterIcon.jsx +9 -0
  62. package/src/components/icons/UserIcon.jsx +9 -0
  63. package/src/components/icons/WhatsappIcon.jsx +9 -0
  64. package/src/components/icons/YoutubeIcon.jsx +9 -0
  65. package/src/components/icons/index.js +60 -0
  66. package/src/components/layout/Footer.jsx +53 -0
  67. package/src/components/layout/Footer.stories.jsx +28 -0
  68. package/src/components/layout/Navbar.jsx +50 -0
  69. package/src/components/layout/Navbar.stories.jsx +42 -0
  70. package/src/components/layout/SubNavbar.jsx +36 -0
  71. package/src/components/modals/Modal.jsx +74 -0
  72. package/src/components/modals/Modal.stories.jsx +93 -0
  73. package/src/components/progressBar/ProgressBar.jsx +113 -0
  74. package/src/components/progressBar/ProgressBar.stories.jsx +67 -0
  75. package/src/context/ThemeContext.jsx +100 -0
  76. package/src/index.js +33 -0
  77. package/src/layouts/Layout.jsx +24 -0
  78. package/src/server/index.js +173 -0
  79. package/README.md +0 -36
  80. 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 };