notionsoft-ui 1.0.34 → 1.0.35
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/cache-svg/CachedSvg.stories.tsx +74 -0
- package/src/notion-ui/cache-svg/cached-svg.tsx +150 -0
- package/src/notion-ui/cache-svg/index.ts +3 -0
- package/src/notion-ui/cache-svg/utils.ts +7 -0
- 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/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
|
@@ -78,6 +78,9 @@ interface PhoneInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
|
78
78
|
rootDivClassName?: string;
|
|
79
79
|
iconClassName?: string;
|
|
80
80
|
};
|
|
81
|
+
text: {
|
|
82
|
+
searchInputPlaceholder: string;
|
|
83
|
+
};
|
|
81
84
|
measurement?: PhoneInputSize;
|
|
82
85
|
ROW_HEIGHT?: number;
|
|
83
86
|
VISIBLE_ROWS?: number;
|
|
@@ -97,11 +100,14 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
|
|
|
97
100
|
ROW_HEIGHT = 32,
|
|
98
101
|
VISIBLE_ROWS = 10,
|
|
99
102
|
BUFFER = 5,
|
|
103
|
+
text,
|
|
100
104
|
...rest
|
|
101
105
|
}) => {
|
|
102
106
|
const { rootDivClassName, iconClassName = "size-4" } = classNames || {};
|
|
103
107
|
const [open, setOpen] = useState(false);
|
|
104
108
|
const [highlightedIndex, setHighlightedIndex] = useState<number>(0);
|
|
109
|
+
const { searchInputPlaceholder } = text;
|
|
110
|
+
|
|
105
111
|
const initialCountry = (() => {
|
|
106
112
|
if (typeof value === "string" && value.startsWith("+")) {
|
|
107
113
|
const matched = defaultCountries.find((c) =>
|
|
@@ -119,6 +125,20 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
|
|
|
119
125
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
120
126
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
121
127
|
const [position, setPosition] = useState({ top: 0, left: 0, width: 0 });
|
|
128
|
+
const [search, setSearch] = useState("");
|
|
129
|
+
const filteredCountries = useMemo(() => {
|
|
130
|
+
if (!search.trim()) return defaultCountries;
|
|
131
|
+
const s = search.toLowerCase();
|
|
132
|
+
return defaultCountries.filter(
|
|
133
|
+
(c) =>
|
|
134
|
+
c.name.toLowerCase().includes(s) ||
|
|
135
|
+
c.iso2.toLowerCase().includes(s) ||
|
|
136
|
+
("+" + c.dialCode).includes(s)
|
|
137
|
+
);
|
|
138
|
+
}, [search]);
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
setHighlightedIndex(0);
|
|
141
|
+
}, [search]);
|
|
122
142
|
|
|
123
143
|
const [dropDirection, setDropDirection] = useState<"down" | "up">("down");
|
|
124
144
|
|
|
@@ -163,14 +183,14 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
|
|
|
163
183
|
|
|
164
184
|
if (e.key === "ArrowDown") {
|
|
165
185
|
setHighlightedIndex((prev) =>
|
|
166
|
-
Math.min(prev + 1,
|
|
186
|
+
Math.min(prev + 1, filteredCountries.length - 1)
|
|
167
187
|
);
|
|
168
188
|
e.preventDefault();
|
|
169
189
|
} else if (e.key === "ArrowUp") {
|
|
170
190
|
setHighlightedIndex((prev) => Math.max(prev - 1, 0));
|
|
171
191
|
e.preventDefault();
|
|
172
192
|
} else if (e.key === "Enter") {
|
|
173
|
-
chooseCountry(
|
|
193
|
+
chooseCountry(filteredCountries[highlightedIndex]);
|
|
174
194
|
e.preventDefault();
|
|
175
195
|
} else if (e.key === "Escape") {
|
|
176
196
|
setOpen(false);
|
|
@@ -247,7 +267,7 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
|
|
|
247
267
|
}
|
|
248
268
|
};
|
|
249
269
|
|
|
250
|
-
useLayoutEffect(() => updateDropdownPosition(), [open]);
|
|
270
|
+
useLayoutEffect(() => updateDropdownPosition(), [open, search]);
|
|
251
271
|
useEffect(() => {
|
|
252
272
|
if (!open) return;
|
|
253
273
|
window.addEventListener("resize", updateDropdownPosition);
|
|
@@ -357,6 +377,7 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
|
|
|
357
377
|
+{country.dialCode}
|
|
358
378
|
</span>
|
|
359
379
|
</button>
|
|
380
|
+
|
|
360
381
|
<input
|
|
361
382
|
ref={inputRef}
|
|
362
383
|
type="tel"
|
|
@@ -377,16 +398,14 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
|
|
|
377
398
|
"focus-visible:border-tertiary/60",
|
|
378
399
|
"[&::-webkit-outer-spin-button]:appearance-none",
|
|
379
400
|
"[&::-webkit-inner-spin-button]:appearance-none",
|
|
380
|
-
"[-moz-appearance:textfield] ",
|
|
401
|
+
"[-moz-appearance:textfield] rtl:text-right",
|
|
381
402
|
hasError && "border-red-400",
|
|
382
403
|
className
|
|
383
404
|
)}
|
|
384
405
|
{...rest}
|
|
385
406
|
disabled={readOnly}
|
|
386
|
-
dir="ltr"
|
|
387
407
|
/>
|
|
388
408
|
</div>
|
|
389
|
-
|
|
390
409
|
{open &&
|
|
391
410
|
createPortal(
|
|
392
411
|
<div
|
|
@@ -403,10 +422,21 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
|
|
|
403
422
|
}}
|
|
404
423
|
role="listbox"
|
|
405
424
|
>
|
|
425
|
+
{/* 🔍 Search bar */}
|
|
426
|
+
<div className="p-2 border-b bg-card sticky top-0 z-10">
|
|
427
|
+
<input
|
|
428
|
+
type="text"
|
|
429
|
+
autoFocus
|
|
430
|
+
className="w-full px-2 py-1 text-sm border rounded-sm bg-input/30 focus:outline-none"
|
|
431
|
+
placeholder={searchInputPlaceholder}
|
|
432
|
+
value={search}
|
|
433
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
434
|
+
/>
|
|
435
|
+
</div>
|
|
406
436
|
<VirtualList
|
|
407
437
|
ROW_HEIGHT={ROW_HEIGHT}
|
|
408
438
|
BUFFER={BUFFER}
|
|
409
|
-
items={
|
|
439
|
+
items={filteredCountries}
|
|
410
440
|
height={ROW_HEIGHT * VISIBLE_ROWS}
|
|
411
441
|
renderRow={(c, i) => (
|
|
412
442
|
<div
|
|
@@ -446,7 +476,7 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
|
|
|
446
476
|
}}
|
|
447
477
|
intersectionArgs={{ once: true, rootMargin: "-5% 0%" }}
|
|
448
478
|
>
|
|
449
|
-
<h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-
|
|
479
|
+
<h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-[11px]">
|
|
450
480
|
{errorMessage}
|
|
451
481
|
</h1>
|
|
452
482
|
</AnimatedItem>
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { cn } from "../../utils/cn";
|
|
2
2
|
|
|
3
|
-
export interface ShimmerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
3
|
+
export interface ShimmerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
4
|
+
stop?: boolean;
|
|
5
|
+
}
|
|
4
6
|
|
|
5
|
-
export default function Shimmer({
|
|
7
|
+
export default function Shimmer({
|
|
8
|
+
stop = false,
|
|
9
|
+
className,
|
|
10
|
+
children,
|
|
11
|
+
}: ShimmerProps) {
|
|
6
12
|
return (
|
|
7
13
|
<div
|
|
8
14
|
className={cn("relative w-full overflow-hidden *:rounded-sm", className)}
|
|
@@ -30,7 +36,7 @@ export default function Shimmer({ className, children }: ShimmerProps) {
|
|
|
30
36
|
var(--from-shimmer) 25%
|
|
31
37
|
)`,
|
|
32
38
|
backgroundSize: "1200px 100%",
|
|
33
|
-
animation: "shimmer 2.2s linear infinite",
|
|
39
|
+
animation: !stop ? "shimmer 2.2s linear infinite" : "",
|
|
34
40
|
}}
|
|
35
41
|
/>
|
|
36
42
|
|
|
@@ -7,11 +7,7 @@ interface ShiningTextProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
|
7
7
|
text: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export
|
|
11
|
-
text,
|
|
12
|
-
className,
|
|
13
|
-
...props
|
|
14
|
-
}: ShiningTextProps) {
|
|
10
|
+
export function ShiningText({ text, className, ...props }: ShiningTextProps) {
|
|
15
11
|
// Animate strictly left → right
|
|
16
12
|
const styles = useSpring({
|
|
17
13
|
from: { backgroundPosition: "-100% 0%" }, // start offscreen left
|
|
@@ -27,7 +23,7 @@ export default function ShiningText({
|
|
|
27
23
|
...styles,
|
|
28
24
|
}}
|
|
29
25
|
className={cn(
|
|
30
|
-
"bg-gradient-to-r
|
|
26
|
+
"bg-gradient-to-r text-md font-medium from-black via-gray-100 to-black", // left→right gradient
|
|
31
27
|
"bg-clip-text text-transparent",
|
|
32
28
|
className
|
|
33
29
|
)}
|
|
@@ -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/cache-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
|
+
};
|