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.
Files changed (33) hide show
  1. package/.storybook/main.ts +19 -13
  2. package/.storybook/preview.css +0 -16
  3. package/package.json +7 -2
  4. package/src/notion-ui/animated-item/animated-item.tsx +1 -1
  5. package/src/notion-ui/animated-item/index.ts +1 -1
  6. package/src/notion-ui/button/Button.stories.tsx +31 -8
  7. package/src/notion-ui/button/button.tsx +10 -2
  8. package/src/notion-ui/button-spinner/ButtonSpinner.stories.tsx +42 -34
  9. package/src/notion-ui/button-spinner/button-spinner.tsx +4 -5
  10. package/src/notion-ui/cache-svg/CachedSvg.stories.tsx +74 -0
  11. package/src/notion-ui/cache-svg/cached-svg.tsx +150 -0
  12. package/src/notion-ui/cache-svg/index.ts +3 -0
  13. package/src/notion-ui/cache-svg/utils.ts +7 -0
  14. package/src/notion-ui/cached-image/cached-image.stories.tsx +109 -0
  15. package/src/notion-ui/cached-image/cached-image.tsx +213 -0
  16. package/src/notion-ui/cached-image/index.ts +3 -0
  17. package/src/notion-ui/cached-image/utils.ts +7 -0
  18. package/src/notion-ui/date-picker/DatePicker.stories.tsx +0 -2
  19. package/src/notion-ui/date-picker/date-picker.tsx +5 -5
  20. package/src/notion-ui/input/Input.stories.tsx +1 -1
  21. package/src/notion-ui/input/input.tsx +5 -4
  22. package/src/notion-ui/multi-date-picker/multi-date-picker.tsx +5 -5
  23. package/src/notion-ui/page-size-select/page-size-select.tsx +29 -12
  24. package/src/notion-ui/password-input/password-input.tsx +3 -3
  25. package/src/notion-ui/phone-input/phone-input.tsx +38 -8
  26. package/src/notion-ui/shimmer/shimmer.tsx +9 -3
  27. package/src/notion-ui/shining-text/shining-text.tsx +2 -6
  28. package/src/notion-ui/sidebar/index.ts +3 -0
  29. package/src/notion-ui/sidebar/sidebar-item.tsx +198 -0
  30. package/src/notion-ui/sidebar/sidebar.stories.tsx +181 -0
  31. package/src/notion-ui/sidebar/sidebar.tsx +284 -0
  32. package/src/notion-ui/textarea/Textarea.stories.tsx +1 -1
  33. 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, defaultCountries.length - 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(defaultCountries[highlightedIndex]);
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={defaultCountries}
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-sm-ltr">
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({ className, children }: ShimmerProps) {
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 default function ShiningText({
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 from-gray-300 via-white to-gray-300", // left→right gradient
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,3 @@
1
+ import Sidebar from "./sidebar";
2
+
3
+ export default Sidebar;
@@ -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
+ };