notionsoft-ui 1.0.34 → 1.0.36
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/.storybook/main.ts +19 -13
- package/.storybook/preview.css +0 -16
- package/package.json +7 -2
- package/src/notion-ui/animated-item/animated-item.tsx +1 -1
- package/src/notion-ui/animated-item/index.ts +1 -1
- package/src/notion-ui/button/Button.stories.tsx +31 -8
- package/src/notion-ui/button/button.tsx +10 -2
- package/src/notion-ui/button-spinner/ButtonSpinner.stories.tsx +42 -34
- package/src/notion-ui/button-spinner/button-spinner.tsx +4 -5
- package/src/notion-ui/cached-image/cached-image.stories.tsx +109 -0
- package/src/notion-ui/cached-image/cached-image.tsx +213 -0
- package/src/notion-ui/cached-image/index.ts +3 -0
- package/src/notion-ui/cached-image/utils.ts +7 -0
- package/src/notion-ui/cached-svg/CachedSvg.stories.tsx +74 -0
- package/src/notion-ui/cached-svg/cached-svg.tsx +150 -0
- package/src/notion-ui/cached-svg/index.ts +3 -0
- package/src/notion-ui/cached-svg/utils.ts +7 -0
- package/src/notion-ui/date-picker/DatePicker.stories.tsx +0 -2
- package/src/notion-ui/date-picker/date-picker.tsx +5 -5
- package/src/notion-ui/input/Input.stories.tsx +1 -1
- package/src/notion-ui/input/input.tsx +5 -4
- package/src/notion-ui/multi-date-picker/multi-date-picker.tsx +5 -5
- package/src/notion-ui/page-size-select/page-size-select.tsx +29 -12
- package/src/notion-ui/password-input/password-input.tsx +3 -3
- package/src/notion-ui/phone-input/phone-input.tsx +38 -8
- package/src/notion-ui/shimmer/shimmer.tsx +9 -3
- package/src/notion-ui/shining-text/shining-text.tsx +2 -6
- package/src/notion-ui/sidebar/index.ts +3 -0
- package/src/notion-ui/sidebar/sidebar-item.tsx +198 -0
- package/src/notion-ui/sidebar/sidebar.stories.tsx +181 -0
- package/src/notion-ui/sidebar/sidebar.tsx +284 -0
- package/src/notion-ui/textarea/Textarea.stories.tsx +1 -1
- package/src/notion-ui/textarea/textarea.tsx +3 -3
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { useMemo, useCallback, memo, useEffect, useState } from "react";
|
|
2
|
+
import { ChevronRight } from "lucide-react";
|
|
3
|
+
import { useLocation } from "react-router";
|
|
4
|
+
import CachedSvg, {
|
|
5
|
+
CachedSvgProps,
|
|
6
|
+
} from "@/components/notion-ui/cached-svg/cached-svg";
|
|
7
|
+
import AnimatedItem from "@/components/notion-ui/animated-item";
|
|
8
|
+
import { cn } from "@/utils/cn";
|
|
9
|
+
|
|
10
|
+
export interface SubPermission {
|
|
11
|
+
id: number;
|
|
12
|
+
name: string;
|
|
13
|
+
is_category: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type Permission = {
|
|
17
|
+
id: number;
|
|
18
|
+
visible: boolean;
|
|
19
|
+
permission: string;
|
|
20
|
+
icon: string;
|
|
21
|
+
sub: Map<number, SubPermission>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export interface SidebarItemProps {
|
|
25
|
+
path: string;
|
|
26
|
+
isActive: boolean;
|
|
27
|
+
permission: Permission;
|
|
28
|
+
icon: CachedSvgProps;
|
|
29
|
+
navigateTo: (path: string) => void;
|
|
30
|
+
translate?: (key: string) => string;
|
|
31
|
+
classNames?: {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const SidebarItem = memo(function SidebarItem({
|
|
35
|
+
isActive,
|
|
36
|
+
navigateTo,
|
|
37
|
+
permission,
|
|
38
|
+
path,
|
|
39
|
+
icon,
|
|
40
|
+
translate,
|
|
41
|
+
}: SidebarItemProps) {
|
|
42
|
+
const location = useLocation();
|
|
43
|
+
const [showDropdown, setShowDropdown] = useState(false);
|
|
44
|
+
|
|
45
|
+
// Calculate categories and selectedSubId
|
|
46
|
+
const { categories, selectedSubId } = useMemo(() => {
|
|
47
|
+
const subs = Array.from(permission.sub.values()).filter(
|
|
48
|
+
(sub) => sub.is_category
|
|
49
|
+
);
|
|
50
|
+
const selectedId = Number(location.pathname.split("/").pop());
|
|
51
|
+
return { categories: subs, selectedSubId: selectedId };
|
|
52
|
+
}, [permission.sub, location.pathname]);
|
|
53
|
+
|
|
54
|
+
// Auto-open dropdown if current URL matches any category
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const matched = categories.find((sub) =>
|
|
57
|
+
location.pathname.includes(`${path}/${sub.id}`)
|
|
58
|
+
);
|
|
59
|
+
setShowDropdown(matched ? true : false);
|
|
60
|
+
}, [location.pathname, categories, path]);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const handleCloseDropdowns = (e: Event) => {
|
|
63
|
+
const customEvent = e as CustomEvent;
|
|
64
|
+
if (customEvent.detail?.forceClose) {
|
|
65
|
+
setShowDropdown(false);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Listen for close events
|
|
70
|
+
window.addEventListener("sidebar-close-dropdowns", handleCloseDropdowns);
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
window.removeEventListener(
|
|
74
|
+
"sidebar-close-dropdowns",
|
|
75
|
+
handleCloseDropdowns
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
}, []);
|
|
79
|
+
const handleClick = useCallback(
|
|
80
|
+
(e: React.MouseEvent) => {
|
|
81
|
+
if (categories.length === 0) {
|
|
82
|
+
navigateTo(path);
|
|
83
|
+
} else {
|
|
84
|
+
setShowDropdown((prev) => !prev);
|
|
85
|
+
// Dispatch custom event to parent (Sidebar)
|
|
86
|
+
const expandEvent = new CustomEvent("sidebar-item-expand", {
|
|
87
|
+
bubbles: true, // This makes the event bubble up through DOM
|
|
88
|
+
composed: true, // This allows it to cross shadow DOM boundaries if any
|
|
89
|
+
detail: {
|
|
90
|
+
hasChildren: true,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Dispatch from the clicked element
|
|
95
|
+
e.currentTarget.dispatchEvent(expandEvent);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
[categories.length, navigateTo, path]
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const handleCategoryClick = useCallback(
|
|
102
|
+
(cat: SubPermission) => {
|
|
103
|
+
navigateTo(`${path}/${cat.id}`);
|
|
104
|
+
},
|
|
105
|
+
[navigateTo, path]
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const spring = useMemo(
|
|
109
|
+
() => ({
|
|
110
|
+
springProps: {
|
|
111
|
+
from: { opacity: 0, transform: "translateY(-8px)" },
|
|
112
|
+
config: { mass: 1, tension: 210, friction: 20 },
|
|
113
|
+
to: { opacity: 1, transform: "translateY(0px)" },
|
|
114
|
+
},
|
|
115
|
+
intersectionArgs: { rootMargin: "-10% 0%", once: true },
|
|
116
|
+
}),
|
|
117
|
+
[]
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const dropdownContent = useMemo(() => {
|
|
121
|
+
if (!showDropdown || categories.length === 0) return null;
|
|
122
|
+
return (
|
|
123
|
+
<div className="relative ltr:ml-5 rtl:mr-5 mt-1 mb-4 space-y-1 ltr:pl-2 rtl:pr-2 before:absolute before:top-3 before:bottom-0 rtl:before:right-1 ltr:before:left-1 before:w-px rounded-full before:bg-primary/30">
|
|
124
|
+
{categories.map((cat, index) => {
|
|
125
|
+
const selected = selectedSubId === cat.id;
|
|
126
|
+
return (
|
|
127
|
+
<AnimatedItem
|
|
128
|
+
key={cat.id}
|
|
129
|
+
springProps={{
|
|
130
|
+
...spring.springProps,
|
|
131
|
+
delay: index * 100,
|
|
132
|
+
to: {
|
|
133
|
+
...spring.springProps.to,
|
|
134
|
+
delay: index * 100,
|
|
135
|
+
},
|
|
136
|
+
}}
|
|
137
|
+
intersectionArgs={spring.intersectionArgs}
|
|
138
|
+
>
|
|
139
|
+
<div className="relative flex items-center before:absolute ltr:before:left-1 rtl:before:right-1 before:top-1/2 before:w-3 before:h-px before:bg-primary/40">
|
|
140
|
+
<button
|
|
141
|
+
onClick={handleCategoryClick.bind(null, cat)}
|
|
142
|
+
className={`cursor-pointer text-primary/80 ltr:ml-5 rtl:mr-5 rtl:text-sm rtl:font-bold ltr:text-xs flex items-center gap-x-2 py-1 px-2 w-[85%] rounded-sm transition-colors ${
|
|
143
|
+
selected
|
|
144
|
+
? "font-semibold bg-tertiary/10 text-primary"
|
|
145
|
+
: "hover:opacity-75"
|
|
146
|
+
}`}
|
|
147
|
+
>
|
|
148
|
+
{translate ? translate(cat.name) : cat.name}
|
|
149
|
+
</button>
|
|
150
|
+
</div>
|
|
151
|
+
</AnimatedItem>
|
|
152
|
+
);
|
|
153
|
+
})}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}, [
|
|
157
|
+
showDropdown,
|
|
158
|
+
categories,
|
|
159
|
+
selectedSubId,
|
|
160
|
+
spring,
|
|
161
|
+
handleCategoryClick,
|
|
162
|
+
translate,
|
|
163
|
+
]);
|
|
164
|
+
return (
|
|
165
|
+
<>
|
|
166
|
+
<div
|
|
167
|
+
onClick={handleClick}
|
|
168
|
+
className={cn(
|
|
169
|
+
`grid grid-cols-[1fr_auto] ltr:py-2 rtl:p-1 ltr:pl-2.5 rtl:pr-2 ltr:mx-1 rtl:mx-1.5 text-primary items-center rtl:text-lg ltr:text-xs cursor-pointer rounded-md ${
|
|
170
|
+
isActive
|
|
171
|
+
? `bg-tertiary/90 text-card font-semibold`
|
|
172
|
+
: "hover:opacity-75"
|
|
173
|
+
}`
|
|
174
|
+
)}
|
|
175
|
+
key={permission.permission}
|
|
176
|
+
>
|
|
177
|
+
<div className="flex items-center gap-x-4 w-full">
|
|
178
|
+
<CachedSvg {...icon} className={cn("rtl", icon.className)} />
|
|
179
|
+
<h1 className="truncate">
|
|
180
|
+
{translate
|
|
181
|
+
? translate(permission.permission)
|
|
182
|
+
: permission.permission}
|
|
183
|
+
</h1>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{categories.length > 0 && (
|
|
187
|
+
<ChevronRight
|
|
188
|
+
className={`size-3.5 min-h-3.5 min-w-3.5 text-primary ltr:mr-2 transition-transform duration-300 ease-in-out ${
|
|
189
|
+
showDropdown ? "rotate-90" : "rtl:rotate-180"
|
|
190
|
+
}`}
|
|
191
|
+
/>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
{dropdownContent}
|
|
196
|
+
</>
|
|
197
|
+
);
|
|
198
|
+
}); // Pass custom comparison function
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { MemoryRouter, Route, Routes } from "react-router";
|
|
3
|
+
import { I18nextProvider } from "react-i18next";
|
|
4
|
+
import i18n from "i18next";
|
|
5
|
+
import { initReactI18next } from "react-i18next";
|
|
6
|
+
|
|
7
|
+
import { LucideCircleArrowOutDownLeft } from "lucide-react";
|
|
8
|
+
import { useCallback, useMemo, useState } from "react";
|
|
9
|
+
import { SidebarItem } from "@/components/notion-ui/sidebar/sidebar-item";
|
|
10
|
+
import Sidebar, { Separator } from "@/components/notion-ui/sidebar/sidebar";
|
|
11
|
+
import Button from "@/components/notion-ui/button";
|
|
12
|
+
|
|
13
|
+
/* ------------------ i18n Mock ------------------ */
|
|
14
|
+
i18n.use(initReactI18next).init({
|
|
15
|
+
lng: "en",
|
|
16
|
+
fallbackLng: "en",
|
|
17
|
+
resources: {
|
|
18
|
+
en: {
|
|
19
|
+
translation: {
|
|
20
|
+
app_name: "My App",
|
|
21
|
+
dashboard: "Dashboard",
|
|
22
|
+
settings: "Settings",
|
|
23
|
+
users: "Users",
|
|
24
|
+
roles: "Roles",
|
|
25
|
+
exit_dashb: "Exit",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/* ------------------ Mock Data ------------------ */
|
|
32
|
+
|
|
33
|
+
const permissions = new Map([
|
|
34
|
+
[
|
|
35
|
+
"users",
|
|
36
|
+
{
|
|
37
|
+
id: 1,
|
|
38
|
+
visible: true,
|
|
39
|
+
permission: "users",
|
|
40
|
+
icon: "icons/users.svg",
|
|
41
|
+
sub: new Map([
|
|
42
|
+
[1, { id: 1, name: "roles", is_category: true }],
|
|
43
|
+
[2, { id: 2, name: "permissions", is_category: true }],
|
|
44
|
+
]),
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
[
|
|
48
|
+
"reports",
|
|
49
|
+
{
|
|
50
|
+
id: 2,
|
|
51
|
+
visible: true,
|
|
52
|
+
permission: "reports",
|
|
53
|
+
icon: "icons/reports.svg",
|
|
54
|
+
sub: new Map(),
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/* ------------------ Story Component ------------------ */
|
|
60
|
+
|
|
61
|
+
function StorySidebar() {
|
|
62
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
63
|
+
|
|
64
|
+
const navigateTo = useCallback((path: string) => {
|
|
65
|
+
console.log("Navigate:", path);
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const sidebarItems = useMemo(() => {
|
|
69
|
+
return (
|
|
70
|
+
<>
|
|
71
|
+
<SidebarItem
|
|
72
|
+
path="/dashboard"
|
|
73
|
+
permission={{
|
|
74
|
+
id: 0,
|
|
75
|
+
visible: true,
|
|
76
|
+
permission: "dashboard",
|
|
77
|
+
icon: "icons/home.svg",
|
|
78
|
+
sub: new Map(),
|
|
79
|
+
}}
|
|
80
|
+
isActive
|
|
81
|
+
navigateTo={navigateTo}
|
|
82
|
+
translate={(k) => i18n.t(k)}
|
|
83
|
+
icon={{
|
|
84
|
+
apiConfig: {
|
|
85
|
+
src: "https://www.svgrepo.com/show/521994/bag.svg",
|
|
86
|
+
},
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
|
|
90
|
+
{Array.from(permissions.values()).map((perm) => (
|
|
91
|
+
<SidebarItem
|
|
92
|
+
key={perm.permission}
|
|
93
|
+
path={`/dashboard/${perm.permission}`}
|
|
94
|
+
permission={perm}
|
|
95
|
+
isActive={false}
|
|
96
|
+
navigateTo={navigateTo}
|
|
97
|
+
translate={(k) => i18n.t(k)}
|
|
98
|
+
icon={{
|
|
99
|
+
apiConfig: {
|
|
100
|
+
src: "https://www.svgrepo.com/show/521994/bag.svg",
|
|
101
|
+
},
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
))}
|
|
105
|
+
|
|
106
|
+
<Separator className="my-4" />
|
|
107
|
+
|
|
108
|
+
<SidebarItem
|
|
109
|
+
path="/dashboard/settings"
|
|
110
|
+
permission={{
|
|
111
|
+
id: 99,
|
|
112
|
+
visible: true,
|
|
113
|
+
permission: "settings",
|
|
114
|
+
icon: "icons/settings.svg",
|
|
115
|
+
sub: new Map(),
|
|
116
|
+
}}
|
|
117
|
+
isActive={false}
|
|
118
|
+
navigateTo={navigateTo}
|
|
119
|
+
translate={(k) => i18n.t(k)}
|
|
120
|
+
icon={{
|
|
121
|
+
apiConfig: {
|
|
122
|
+
src: "https://www.svgrepo.com/show/521994/bag.svg",
|
|
123
|
+
},
|
|
124
|
+
}}
|
|
125
|
+
/>
|
|
126
|
+
</>
|
|
127
|
+
);
|
|
128
|
+
}, [navigateTo]);
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<Sidebar collapsed={collapsed} setCollapsed={setCollapsed}>
|
|
132
|
+
<Sidebar.Header>
|
|
133
|
+
<img
|
|
134
|
+
src="https://placehold.co/48x40"
|
|
135
|
+
className="w-12 h-10 rounded-lg"
|
|
136
|
+
/>
|
|
137
|
+
<h1 className={`text-sm font-semibold ${collapsed && "lg:hidden"}`}>
|
|
138
|
+
{i18n.t("app_name")}
|
|
139
|
+
</h1>
|
|
140
|
+
</Sidebar.Header>
|
|
141
|
+
|
|
142
|
+
<Sidebar.Content className="pt-5">{sidebarItems}</Sidebar.Content>
|
|
143
|
+
|
|
144
|
+
<Sidebar.Footer>
|
|
145
|
+
<Button
|
|
146
|
+
variant="icon"
|
|
147
|
+
onClick={() => console.log("Exit")}
|
|
148
|
+
className="mb-4 mx-auto text-xs font-semibold"
|
|
149
|
+
>
|
|
150
|
+
<LucideCircleArrowOutDownLeft className="size-[18px]" />
|
|
151
|
+
<span className={collapsed ? "lg:hidden" : ""}>
|
|
152
|
+
{i18n.t("exit_dashb")}
|
|
153
|
+
</span>
|
|
154
|
+
</Button>
|
|
155
|
+
</Sidebar.Footer>
|
|
156
|
+
</Sidebar>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ------------------ Storybook Meta ------------------ */
|
|
161
|
+
|
|
162
|
+
const meta: Meta = {
|
|
163
|
+
title: "Layout/AppSidebar",
|
|
164
|
+
parameters: {
|
|
165
|
+
layout: "fullscreen",
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export default meta;
|
|
170
|
+
|
|
171
|
+
export const Default: StoryObj = {
|
|
172
|
+
render: () => (
|
|
173
|
+
<MemoryRouter initialEntries={["/dashboard"]}>
|
|
174
|
+
<I18nextProvider i18n={i18n}>
|
|
175
|
+
<Routes>
|
|
176
|
+
<Route path="*" element={<StorySidebar />} />
|
|
177
|
+
</Routes>
|
|
178
|
+
</I18nextProvider>
|
|
179
|
+
</MemoryRouter>
|
|
180
|
+
),
|
|
181
|
+
};
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import React, { forwardRef, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { cn } from "@/utils/cn";
|
|
3
|
+
import { AlignLeft, type LucideIcon } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
export interface SidebarProps extends React.HTMLAttributes<HTMLElement> {
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
collapsed?: boolean;
|
|
8
|
+
setCollapsed?: (open: boolean) => void;
|
|
9
|
+
mobileHamburgerIcon?: {
|
|
10
|
+
icon: LucideIcon;
|
|
11
|
+
className?: string;
|
|
12
|
+
onClick?: (e: React.MouseEvent<SVGSVGElement, MouseEvent>) => void;
|
|
13
|
+
};
|
|
14
|
+
desktopHamburgerIcon?: {
|
|
15
|
+
icon: LucideIcon;
|
|
16
|
+
className?: string;
|
|
17
|
+
onClick?: (e: React.MouseEvent<SVGSVGElement, MouseEvent>) => void;
|
|
18
|
+
};
|
|
19
|
+
classNames?: {
|
|
20
|
+
wrapperClassName?: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
interface SidebarExpandEventDetail {
|
|
24
|
+
hasChildren: boolean;
|
|
25
|
+
}
|
|
26
|
+
type SidebarCompound = React.FC<SidebarProps> & {
|
|
27
|
+
Header: typeof SidebarHeader;
|
|
28
|
+
Footer: typeof SidebarFooter;
|
|
29
|
+
Content: typeof SidebarContent;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const Sidebar: SidebarCompound = ({
|
|
33
|
+
children,
|
|
34
|
+
className,
|
|
35
|
+
collapsed,
|
|
36
|
+
setCollapsed,
|
|
37
|
+
mobileHamburgerIcon = { icon: AlignLeft },
|
|
38
|
+
desktopHamburgerIcon = { icon: AlignLeft },
|
|
39
|
+
classNames,
|
|
40
|
+
...rest
|
|
41
|
+
}) => {
|
|
42
|
+
const { wrapperClassName } = classNames || {};
|
|
43
|
+
const [internalCollapsed, setInternalCollapsed] = useState<boolean>(() => {
|
|
44
|
+
const stored = localStorage.getItem("sidebar_collapsed");
|
|
45
|
+
return stored ? JSON.parse(stored) : false;
|
|
46
|
+
});
|
|
47
|
+
const sidebarRef = useRef<HTMLElement>(null); // This is already your nav ref
|
|
48
|
+
const open = collapsed ?? internalCollapsed;
|
|
49
|
+
const [hideSiderbar, setHideSiderbar] = useState(false);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const handleExpandEvent = (e: Event) => {
|
|
53
|
+
const customEvent = e as CustomEvent;
|
|
54
|
+
const detail = customEvent.detail as SidebarExpandEventDetail;
|
|
55
|
+
if (detail?.hasChildren) {
|
|
56
|
+
if (open) {
|
|
57
|
+
if (setCollapsed) {
|
|
58
|
+
// Controlled: call parent callback
|
|
59
|
+
setCollapsed(false);
|
|
60
|
+
} else {
|
|
61
|
+
// Uncontrolled: update internal state + localStorage
|
|
62
|
+
setInternalCollapsed(() => {
|
|
63
|
+
localStorage.setItem("sidebar_collapsed", JSON.stringify(false));
|
|
64
|
+
return false;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
if (sidebarRef.current) {
|
|
72
|
+
sidebarRef.current.addEventListener(
|
|
73
|
+
"sidebar-item-expand",
|
|
74
|
+
handleExpandEvent
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
if (sidebarRef.current) {
|
|
80
|
+
sidebarRef.current.removeEventListener(
|
|
81
|
+
"sidebar-item-expand",
|
|
82
|
+
handleExpandEvent
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}, [open]);
|
|
87
|
+
const toggle = (e: React.MouseEvent<SVGSVGElement, MouseEvent>) => {
|
|
88
|
+
if (desktopHamburgerIcon.onClick) desktopHamburgerIcon.onClick(e);
|
|
89
|
+
if (setCollapsed) {
|
|
90
|
+
// Controlled: call parent callback
|
|
91
|
+
setCollapsed(!open);
|
|
92
|
+
} else {
|
|
93
|
+
// Uncontrolled: update internal state + localStorage
|
|
94
|
+
setInternalCollapsed((prev) => {
|
|
95
|
+
const newValue = !prev;
|
|
96
|
+
localStorage.setItem("sidebar_collapsed", JSON.stringify(newValue));
|
|
97
|
+
return newValue;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
let header: React.ReactNode = null;
|
|
103
|
+
let footer: React.ReactNode = null;
|
|
104
|
+
let content: React.ReactNode = null;
|
|
105
|
+
|
|
106
|
+
React.Children.forEach(children, (child) => {
|
|
107
|
+
if (!React.isValidElement(child)) return;
|
|
108
|
+
|
|
109
|
+
switch (child.type) {
|
|
110
|
+
case SidebarHeader:
|
|
111
|
+
header = child;
|
|
112
|
+
break;
|
|
113
|
+
case SidebarFooter:
|
|
114
|
+
footer = child;
|
|
115
|
+
break;
|
|
116
|
+
case SidebarContent:
|
|
117
|
+
content = child;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
// In Sidebar component, add this useEffect
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
const closeAllDropdowns = () => {
|
|
124
|
+
if (open) {
|
|
125
|
+
// When sidebar is collapsed (w-12)
|
|
126
|
+
// Dispatch an event to tell all sidebar items to close their dropdowns
|
|
127
|
+
const closeEvent = new CustomEvent("sidebar-close-dropdowns", {
|
|
128
|
+
bubbles: true,
|
|
129
|
+
composed: true,
|
|
130
|
+
detail: { forceClose: true },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (sidebarRef.current) {
|
|
134
|
+
sidebarRef.current.dispatchEvent(closeEvent);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
closeAllDropdowns();
|
|
140
|
+
}, [open]); // Run whenever collapsed state changes
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<>
|
|
144
|
+
<div
|
|
145
|
+
onClick={() => setHideSiderbar(false)}
|
|
146
|
+
className={cn(
|
|
147
|
+
"fixed z-50 ltr:left-0 rtl:right-0 lg:hidden top-0 px-2 pt-1",
|
|
148
|
+
hideSiderbar
|
|
149
|
+
? "w-screen h-screen bg-black/20" // Full screen with semi-transparent bg
|
|
150
|
+
: "w-auto h-auto", // Just icon size
|
|
151
|
+
wrapperClassName
|
|
152
|
+
)}
|
|
153
|
+
>
|
|
154
|
+
<mobileHamburgerIcon.icon
|
|
155
|
+
onClick={(e) => {
|
|
156
|
+
e.stopPropagation(); // Prevent event from bubbling to parent
|
|
157
|
+
if (mobileHamburgerIcon.onClick) mobileHamburgerIcon.onClick(e);
|
|
158
|
+
setHideSiderbar(true);
|
|
159
|
+
}}
|
|
160
|
+
className={cn(
|
|
161
|
+
`size-5 mt-2 text-tertiary hover:scale-105 transition-transform mx-auto cursor-pointer`,
|
|
162
|
+
hideSiderbar ? "hidden" : "block",
|
|
163
|
+
mobileHamburgerIcon.className
|
|
164
|
+
)}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
<nav
|
|
168
|
+
ref={sidebarRef}
|
|
169
|
+
{...rest}
|
|
170
|
+
className={cn(
|
|
171
|
+
"overflow-x-hidden bg-card fixed grid lg:grid-rows-[auto_auto_1fr_auto] grid-rows-[auto_1fr_auto] overflow-hidden z-50 transition-all duration-200 dark:text-card-foreground text-primary-foreground h-screen",
|
|
172
|
+
// Mobile behavior
|
|
173
|
+
hideSiderbar
|
|
174
|
+
? "ltr:left-0 rtl:right-0"
|
|
175
|
+
: "ltr:left-[-300px] rtl:right-[-300px]",
|
|
176
|
+
// Desktop behavior - always visible with proper width
|
|
177
|
+
"lg:relative! lg:left-0! lg:right-0! lg:w-[280px]!", // Default desktop width
|
|
178
|
+
// Collapsed state on desktop
|
|
179
|
+
open ? "lg:w-12!" : "lg:w-[280px]",
|
|
180
|
+
// Mobile width
|
|
181
|
+
`w-[280px]`, // Set a proper width for mobile too
|
|
182
|
+
className
|
|
183
|
+
)}
|
|
184
|
+
>
|
|
185
|
+
<desktopHamburgerIcon.icon
|
|
186
|
+
onClick={toggle}
|
|
187
|
+
className={cn(
|
|
188
|
+
`size-5 mt-2 text-tertiary hidden lg:block hover:scale-105 transition-transform mx-auto cursor-pointer`,
|
|
189
|
+
desktopHamburgerIcon.className
|
|
190
|
+
)}
|
|
191
|
+
/>
|
|
192
|
+
{header}
|
|
193
|
+
{content}
|
|
194
|
+
{footer}
|
|
195
|
+
</nav>
|
|
196
|
+
</>
|
|
197
|
+
);
|
|
198
|
+
};
|
|
199
|
+
export interface SidebarContentProps
|
|
200
|
+
extends React.HTMLAttributes<HTMLDivElement> {}
|
|
201
|
+
|
|
202
|
+
// Use forwardRef to accept ref prop
|
|
203
|
+
export const SidebarContent = forwardRef<HTMLDivElement, SidebarContentProps>(
|
|
204
|
+
function SidebarContent({ children, className, ...rest }, ref) {
|
|
205
|
+
return (
|
|
206
|
+
<div
|
|
207
|
+
ref={ref} // Pass the ref here
|
|
208
|
+
{...rest}
|
|
209
|
+
className={cn(
|
|
210
|
+
"overflow-y-auto flex flex-col overflow-x-hidden pb-12",
|
|
211
|
+
className
|
|
212
|
+
)}
|
|
213
|
+
>
|
|
214
|
+
{children}
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
SidebarContent.displayName = "SidebarContent";
|
|
221
|
+
|
|
222
|
+
export interface SidebarFooterProps
|
|
223
|
+
extends React.HTMLAttributes<HTMLDivElement> {}
|
|
224
|
+
|
|
225
|
+
export const SidebarFooter = forwardRef<HTMLDivElement, SidebarContentProps>(
|
|
226
|
+
({ children, className, ...rest }, ref) => {
|
|
227
|
+
return (
|
|
228
|
+
<div
|
|
229
|
+
ref={ref}
|
|
230
|
+
{...rest}
|
|
231
|
+
className={cn(
|
|
232
|
+
"sticky w-full overflow-x-hidden bottom-0 z-50 flex flex-col items-center gap-y-1 border-t border-secondary-foreground/15 pt-4 mt-4 bg-transparent",
|
|
233
|
+
className
|
|
234
|
+
)}
|
|
235
|
+
>
|
|
236
|
+
{children}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
SidebarFooter.displayName = "SidebarFooter";
|
|
243
|
+
|
|
244
|
+
export interface SidebarHeaderProps
|
|
245
|
+
extends React.HTMLAttributes<HTMLDivElement> {}
|
|
246
|
+
|
|
247
|
+
export const SidebarHeader = forwardRef<HTMLDivElement, SidebarContentProps>(
|
|
248
|
+
({ children, className, ...rest }, ref) => {
|
|
249
|
+
return (
|
|
250
|
+
<div
|
|
251
|
+
ref={ref}
|
|
252
|
+
{...rest}
|
|
253
|
+
className={cn(
|
|
254
|
+
"sticky w-full overflow-x-hidden top-0 z-50 flex flex-col justify-center items-center gap-y-1 border-b border-secondary-foreground/15 pb-4 mt-2 bg-transparent",
|
|
255
|
+
className
|
|
256
|
+
)}
|
|
257
|
+
>
|
|
258
|
+
{children}
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
SidebarHeader.displayName = "SidebarHeader";
|
|
265
|
+
|
|
266
|
+
export interface SeparatorProps
|
|
267
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {}
|
|
268
|
+
|
|
269
|
+
export function Separator({ className, ...rest }: SeparatorProps) {
|
|
270
|
+
return (
|
|
271
|
+
<div
|
|
272
|
+
{...rest}
|
|
273
|
+
className={cn("w-full bg-secondary-foreground/15 h-px", className)}
|
|
274
|
+
/>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
Separator.displayName = "Separator";
|
|
278
|
+
|
|
279
|
+
/* Compound attachments */
|
|
280
|
+
Sidebar.Header = SidebarHeader;
|
|
281
|
+
Sidebar.Footer = SidebarFooter;
|
|
282
|
+
Sidebar.Content = SidebarContent;
|
|
283
|
+
|
|
284
|
+
export default Sidebar;
|
|
@@ -44,7 +44,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
44
44
|
>
|
|
45
45
|
<div
|
|
46
46
|
className={cn(
|
|
47
|
-
"relative text-start select-none h-fit rtl:text-
|
|
47
|
+
"relative text-start select-none h-fit rtl:text-[17px] ltr:text-[13px]"
|
|
48
48
|
)}
|
|
49
49
|
>
|
|
50
50
|
{/* Required Hint */}
|
|
@@ -64,7 +64,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
64
64
|
<label
|
|
65
65
|
htmlFor={label}
|
|
66
66
|
className={cn(
|
|
67
|
-
"font-semibold rtl:text-
|
|
67
|
+
"font-semibold rtl:text-md ltr:text-[13px] inline-block pb-1"
|
|
68
68
|
)}
|
|
69
69
|
>
|
|
70
70
|
{label}
|
|
@@ -108,7 +108,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
108
108
|
}}
|
|
109
109
|
intersectionArgs={{ once: true, rootMargin: "-5% 0%" }}
|
|
110
110
|
>
|
|
111
|
-
<h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-
|
|
111
|
+
<h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-[11px]">
|
|
112
112
|
{errorMessage}
|
|
113
113
|
</h1>
|
|
114
114
|
</AnimatedItem>
|