notionsoft-ui 1.0.43 → 1.0.46

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 (97) hide show
  1. package/cli/index.cjs +23 -37
  2. package/package.json +1 -3
  3. package/src/notion-ui/animated-item/animated-item.tsx +6 -4
  4. package/src/notion-ui/animated-item/index.ts +2 -2
  5. package/src/notion-ui/breadcrumb/breadcrumb.tsx +7 -7
  6. package/src/notion-ui/breadcrumb/index.ts +8 -0
  7. package/src/notion-ui/button/Button.stories.tsx +2 -2
  8. package/src/notion-ui/button/button.tsx +11 -11
  9. package/src/notion-ui/button/index.ts +2 -2
  10. package/src/notion-ui/button-spinner/ButtonSpinner.stories.tsx +3 -4
  11. package/src/notion-ui/button-spinner/button-spinner.tsx +3 -5
  12. package/src/notion-ui/button-spinner/index.ts +2 -2
  13. package/src/notion-ui/cached-image/cached-image.stories.tsx +7 -7
  14. package/src/notion-ui/cached-image/cached-image.tsx +44 -23
  15. package/src/notion-ui/cached-image/index.ts +2 -2
  16. package/src/notion-ui/cached-svg/CachedSvg.stories.tsx +9 -11
  17. package/src/notion-ui/cached-svg/cached-svg.tsx +53 -48
  18. package/src/notion-ui/cached-svg/index.ts +2 -2
  19. package/src/notion-ui/card/card.tsx +4 -4
  20. package/src/notion-ui/card/index.ts +18 -19
  21. package/src/notion-ui/checkbox/checkbox.tsx +5 -6
  22. package/src/notion-ui/checkbox/index.ts +3 -0
  23. package/src/notion-ui/circle-loader/CircleLoader.stories.tsx +57 -25
  24. package/src/notion-ui/circle-loader/circle-loader.tsx +6 -6
  25. package/src/notion-ui/circle-loader/index.ts +2 -2
  26. package/src/notion-ui/date-picker/DatePicker.stories.tsx +5 -3
  27. package/src/notion-ui/date-picker/date-picker.tsx +35 -33
  28. package/src/notion-ui/date-picker/index.ts +2 -2
  29. package/src/notion-ui/filter-dialog/FilterDialog.stories.tsx +117 -85
  30. package/src/notion-ui/filter-dialog/filter-dialog.tsx +40 -16
  31. package/src/notion-ui/filter-dialog/index.ts +15 -0
  32. package/src/notion-ui/input/Input.stories.tsx +2 -2
  33. package/src/notion-ui/input/index.ts +2 -2
  34. package/src/notion-ui/input/input.tsx +45 -40
  35. package/src/notion-ui/multi-date-picker/MultiDatePicker.stories.tsx +5 -3
  36. package/src/notion-ui/multi-date-picker/index.ts +6 -2
  37. package/src/notion-ui/multi-date-picker/multi-date-picker.tsx +35 -34
  38. package/src/notion-ui/multi-select-input/helper.ts +17 -0
  39. package/src/notion-ui/multi-select-input/hook.ts +10 -0
  40. package/src/notion-ui/multi-select-input/index.ts +6 -2
  41. package/src/notion-ui/multi-select-input/multi-select-input.stories.tsx +7 -10
  42. package/src/notion-ui/multi-select-input/multi-select-input.tsx +33 -30
  43. package/src/notion-ui/multi-tab-input/index.ts +2 -2
  44. package/src/notion-ui/multi-tab-input/multi-tab-input.tsx +11 -13
  45. package/src/notion-ui/multi-tab-input/multi.tab.input.stories.tsx +4 -4
  46. package/src/notion-ui/multi-tab-textarea/index.ts +2 -2
  47. package/src/notion-ui/multi-tab-textarea/multi-tab-textarea.tsx +8 -9
  48. package/src/notion-ui/multi-tab-textarea/multi.tab.textarea.stories.tsx +6 -7
  49. package/src/notion-ui/page-size-select/index.ts +2 -2
  50. package/src/notion-ui/page-size-select/page-size-select.stories.tsx +1 -1
  51. package/src/notion-ui/page-size-select/page-size-select.tsx +6 -6
  52. package/src/notion-ui/pagination/Pagination.stories.tsx +2 -3
  53. package/src/notion-ui/pagination/index.ts +3 -0
  54. package/src/notion-ui/pagination/pagination.tsx +2 -2
  55. package/src/notion-ui/password-input/helper.ts +21 -0
  56. package/src/notion-ui/password-input/index.ts +6 -2
  57. package/src/notion-ui/password-input/password-input.stories.tsx +4 -6
  58. package/src/notion-ui/password-input/password-input.tsx +9 -28
  59. package/src/notion-ui/phone-input/PhoneInput.stories.tsx +2 -2
  60. package/src/notion-ui/phone-input/index.ts +2 -2
  61. package/src/notion-ui/phone-input/lazy-flag.tsx +1 -1
  62. package/src/notion-ui/phone-input/phone-input.tsx +33 -29
  63. package/src/notion-ui/search-input/helper.ts +17 -0
  64. package/src/notion-ui/search-input/hook.ts +10 -0
  65. package/src/notion-ui/search-input/index.ts +18 -2
  66. package/src/notion-ui/search-input/search-input.tsx +40 -30
  67. package/src/notion-ui/search-input/search.Input.stories.tsx +3 -4
  68. package/src/notion-ui/sheet/AnimatedSheet.stories.tsx +2 -2
  69. package/src/notion-ui/sheet/{AnimatedSheet.tsx → animated-sheet.tsx} +43 -43
  70. package/src/notion-ui/sheet/index.ts +2 -2
  71. package/src/notion-ui/shimmer/index.ts +8 -2
  72. package/src/notion-ui/shimmer/shimmer.stories.tsx +1 -2
  73. package/src/notion-ui/shimmer/shimmer.tsx +6 -10
  74. package/src/notion-ui/shining-text/index.ts +1 -1
  75. package/src/notion-ui/shining-text/shining-text.stories.tsx +1 -1
  76. package/src/notion-ui/shining-text/shining-text.tsx +5 -5
  77. package/src/notion-ui/sidebar/index.ts +34 -2
  78. package/src/notion-ui/sidebar/sidebar-item.tsx +15 -16
  79. package/src/notion-ui/sidebar/sidebar.stories.tsx +22 -45
  80. package/src/notion-ui/sidebar/sidebar.tsx +36 -26
  81. package/src/notion-ui/status-button/index.ts +5 -2
  82. package/src/notion-ui/status-button/status-button.stories.tsx +1 -1
  83. package/src/notion-ui/status-button/status-button.tsx +7 -7
  84. package/src/notion-ui/tab/index.ts +2 -2
  85. package/src/notion-ui/tab/tab.tsx +8 -6
  86. package/src/notion-ui/table/Table.stories.tsx +156 -0
  87. package/src/notion-ui/table/index.ts +23 -0
  88. package/src/notion-ui/table/table.tsx +8 -8
  89. package/src/notion-ui/textarea/Textarea.stories.tsx +1 -1
  90. package/src/notion-ui/textarea/index.ts +2 -2
  91. package/src/notion-ui/textarea/textarea.tsx +12 -15
  92. package/src/utils/cn.ts +0 -26
  93. package/src/utils/helper.ts +5 -0
  94. package/src/utils/hook.ts +2 -0
  95. package/tsconfig.json +2 -0
  96. package/src/notion-ui/cached-image/utils.ts +0 -7
  97. package/src/notion-ui/cached-svg/utils.ts +0 -7
package/cli/index.cjs CHANGED
@@ -6,19 +6,6 @@ const path = require("path");
6
6
  const chalk = require("chalk");
7
7
  const { execSync } = require("child_process");
8
8
 
9
- /* ------------------------------
10
- Helper: get template path
11
- ------------------------------- */
12
- function getTemplateFile(component) {
13
- // Library template: src/notion-ui/button/button.tsx
14
- return path.join(
15
- __dirname,
16
- "../src/notion-ui",
17
- component,
18
- component + ".tsx"
19
- );
20
- }
21
-
22
9
  /* ------------------------------
23
10
  Install dependencies in project
24
11
  ------------------------------- */
@@ -41,7 +28,7 @@ function updateTsConfig() {
41
28
 
42
29
  if (!fs.existsSync(tsconfigPath)) {
43
30
  console.log(
44
- chalk.yellow("⚠ tsconfig.json not found, skipping baseUrl setup.")
31
+ chalk.yellow("⚠ tsconfig.json not found, skipping baseUrl setup."),
45
32
  );
46
33
  return;
47
34
  }
@@ -56,8 +43,8 @@ function updateTsConfig() {
56
43
  fs.writeJsonSync(tsconfigPath, tsconfig, { spaces: 2 });
57
44
  console.log(
58
45
  chalk.green(
59
- "✓ tsconfig.json updated for absolute imports (@utils/*, @components/*)"
60
- )
46
+ "✓ tsconfig.json updated for absolute imports (@utils/*, @components/*)",
47
+ ),
61
48
  );
62
49
  }
63
50
 
@@ -79,7 +66,7 @@ program
79
66
  // Create config
80
67
  fs.writeFileSync(
81
68
  configPath,
82
- JSON.stringify({ componentDir: "src/components/notion-ui" }, null, 2)
69
+ JSON.stringify({ componentDir: "src/components/notion-ui" }, null, 2),
83
70
  );
84
71
 
85
72
  // Ensure component folder
@@ -99,7 +86,7 @@ import { twMerge } from "tailwind-merge";
99
86
 
100
87
  export function cn(...inputs: Parameters<typeof clsx>) {
101
88
  return twMerge(clsx(...inputs));
102
- }`
89
+ }`,
103
90
  );
104
91
  console.log(chalk.green("✓ cn helper created at src/utils/cn.ts"));
105
92
  }
@@ -130,6 +117,7 @@ program
130
117
 
131
118
  const config = fs.readJSONSync(configFile);
132
119
  const templateDir = path.join(__dirname, "../src/notion-ui", component);
120
+ fs.ensureDirSync(path.join(cwd, "src/utils"));
133
121
 
134
122
  if (!fs.existsSync(templateDir)) {
135
123
  console.log(chalk.red(`❌ Component '${component}' does not exist.`));
@@ -170,33 +158,31 @@ program
170
158
  console.log(chalk.green(`✓ ${dataFile} merged to src/utils/dt.ts`));
171
159
  });
172
160
 
173
- // Handle use-*.ts → src/utils/hook.ts
174
- fs.readdirSync(templateDir)
175
- .filter((f) => f.startsWith("use-") && f.endsWith(".ts"))
176
- .forEach((hookFile) => {
177
- const srcHookFile = path.join(templateDir, hookFile);
178
- const destHookFile = path.join(cwd, "src/utils/hook.ts");
179
- appendIfNotExist(srcHookFile, destHookFile);
180
- console.log(chalk.green(`✓ ${hookFile} merged to src/utils/hook.ts`));
181
- });
161
+ // Handle hook.ts → src/utils/hook.ts
162
+ const hookFile = path.join(templateDir, "hook.ts");
163
+ if (fs.existsSync(hookFile)) {
164
+ const destHookFile = path.join(cwd, "src/utils/hook.ts");
165
+ appendIfNotExist(hookFile, destHookFile);
166
+ console.log(chalk.green("✓ hook.ts merged to src/utils/hook.ts"));
167
+ }
182
168
 
183
- // Handle utils.ts → src/utils/helper.ts
184
- const utilsFile = path.join(templateDir, "utils.ts");
185
- if (fs.existsSync(utilsFile)) {
186
- const destUtilsFile = path.join(cwd, "src/utils/helper.ts");
187
- appendIfNotExist(utilsFile, destUtilsFile);
188
- console.log(chalk.green("✓ utils.ts merged to src/utils/helper.ts"));
169
+ // Handle helper.ts → src/utils/helper.ts
170
+ const helperFile = path.join(templateDir, "helper.ts");
171
+ if (fs.existsSync(helperFile)) {
172
+ const destHelperFile = path.join(cwd, "src/utils/helper.ts");
173
+ appendIfNotExist(helperFile, destHelperFile);
174
+ console.log(chalk.green("✓ helper.ts merged to src/utils/helper.ts"));
189
175
  }
190
176
 
191
- // Copy remaining files (exclude index.ts, *.stories.tsx, type.ts, *-data.ts, use-*.ts, utils.ts)
177
+ // Copy remaining files (exclude index.ts, *.stories.tsx, type.ts, *-data.ts, hook.ts, helper.ts)
192
178
  fs.readdirSync(templateDir).forEach((file) => {
193
179
  if (
194
180
  file === "index.ts" ||
195
181
  file === "type.ts" ||
196
182
  file.endsWith("-data.ts") ||
197
183
  file.endsWith(".stories.tsx") ||
198
- (file.startsWith("use-") && file.endsWith(".ts")) ||
199
- file === "utils.ts"
184
+ file === "hook.ts" ||
185
+ file === "helper.ts"
200
186
  )
201
187
  return;
202
188
 
@@ -207,7 +193,7 @@ program
207
193
  });
208
194
 
209
195
  console.log(
210
- chalk.green(`✓ Installed ${component} to ${config.componentDir}`)
196
+ chalk.green(`✓ Installed ${component} to ${config.componentDir}`),
211
197
  );
212
198
  });
213
199
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notionsoft-ui",
3
- "version": "1.0.43",
3
+ "version": "1.0.46",
4
4
  "description": "A React UI component installer (shadcn-style). Installs components directly into your project.",
5
5
  "bin": {
6
6
  "notionsoft-ui": "./cli/index.cjs"
@@ -24,11 +24,9 @@
24
24
  "commander": "^12.0.0",
25
25
  "dompurify": "^3.3.1",
26
26
  "fs-extra": "^11.2.0",
27
- "i18next": "^25.7.3",
28
27
  "lucide-react": "^0.554.0",
29
28
  "react": "^19.2.0",
30
29
  "react-dom": "^19.2.0",
31
- "react-i18next": "^16.5.0",
32
30
  "react-multi-date-picker": "^4.5.2",
33
31
  "react-router": "^7.11.0",
34
32
  "tailwind-merge": "^3.4.0",
@@ -8,7 +8,7 @@ import {
8
8
  } from "@react-spring/web";
9
9
  import { useCallback, useState } from "react";
10
10
 
11
- export interface AnimatedItemProps {
11
+ interface AnimatedItemProps {
12
12
  springProps: {
13
13
  from?: object;
14
14
  to?: object | object[];
@@ -20,7 +20,7 @@ export interface AnimatedItemProps {
20
20
  onStart?: (
21
21
  result: AnimationResult,
22
22
  spring: Controller | SpringValue,
23
- item?: any
23
+ item?: any,
24
24
  ) => void;
25
25
  config?: {
26
26
  mass: number;
@@ -32,14 +32,14 @@ export interface AnimatedItemProps {
32
32
  children: React.ReactNode | ((inView: boolean) => React.ReactNode);
33
33
  }
34
34
 
35
- export function AnimatedItem(props: AnimatedItemProps) {
35
+ function AnimatedItem(props: AnimatedItemProps) {
36
36
  const [inView, setInView] = useState(false);
37
37
  const { springProps, intersectionArgs, children } = props;
38
38
  const defaultOnStart = useCallback(
39
39
  () => {
40
40
  setInView(true);
41
41
  },
42
- [] // no dependencies, memoized once
42
+ [], // no dependencies, memoized once
43
43
  );
44
44
  const composedOnStart = springProps.onStart ?? defaultOnStart;
45
45
 
@@ -52,3 +52,5 @@ export function AnimatedItem(props: AnimatedItemProps) {
52
52
  </animated.div>
53
53
  );
54
54
  }
55
+
56
+ export { AnimatedItem, type AnimatedItemProps };
@@ -1,3 +1,3 @@
1
- import { AnimatedItem } from "./animated-item";
1
+ import { AnimatedItem, AnimatedItemProps } from "./animated-item";
2
2
 
3
- export default AnimatedItem;
3
+ export { AnimatedItem, type AnimatedItemProps };
@@ -13,13 +13,13 @@ const Breadcrumb = React.forwardRef<HTMLDivElement, BreadcrumbProps>(
13
13
  {...rest}
14
14
  className={cn(
15
15
  "rounded-sm px-5 items-center border border-primary/15 bg-card w-full sm:w-fit overflow-x-auto flex gap-x-4",
16
- className
16
+ className,
17
17
  )}
18
18
  >
19
19
  {children}
20
20
  </div>
21
21
  );
22
- }
22
+ },
23
23
  );
24
24
 
25
25
  interface BreadcrumbSeparatorProps extends React.SVGProps<SVGSVGElement> {}
@@ -40,7 +40,7 @@ const BreadcrumbSeparator = React.forwardRef<
40
40
  aria-hidden="true"
41
41
  className={cn(
42
42
  "text-primary/15 min-h-9 min-w-4 h-9 w-4 rtl:rotate-180",
43
- className
43
+ className,
44
44
  )}
45
45
  >
46
46
  <path d="M.293 0l22 22-22 22h1.414l22-22-22-22H.293z"></path>
@@ -60,13 +60,13 @@ const BreadcrumbItem = React.forwardRef<HTMLDivElement, BreadcrumbItemProps>(
60
60
  {...rest}
61
61
  className={cn(
62
62
  "text-primary/70 rtl:pt-0.5 hover:text-primary text-nowrap capitalize cursor-pointer transition-colors duration-200 font-medium rtl:text-4 ltr:text-xs",
63
- className
63
+ className,
64
64
  )}
65
65
  >
66
66
  {children}
67
67
  </div>
68
68
  );
69
- }
69
+ },
70
70
  );
71
71
 
72
72
  interface BreadcrumbHomeProps extends React.SVGProps<SVGSVGElement> {}
@@ -85,7 +85,7 @@ const BreadcrumbHome = React.forwardRef<SVGSVGElement, BreadcrumbHomeProps>(
85
85
  // data-slot="icon"
86
86
  className={cn(
87
87
  "text-primary/60 fill-primary/60 hover:scale-105 min-w-4 min-h-4 size-4 hover:fill-primary/90 transition-[fill] duration-300 cursor-pointer",
88
- className
88
+ className,
89
89
  )}
90
90
  >
91
91
  <path
@@ -95,7 +95,7 @@ const BreadcrumbHome = React.forwardRef<SVGSVGElement, BreadcrumbHomeProps>(
95
95
  ></path>
96
96
  </svg>
97
97
  );
98
- }
98
+ },
99
99
  );
100
100
 
101
101
  export { Breadcrumb, BreadcrumbSeparator, BreadcrumbItem, BreadcrumbHome };
@@ -0,0 +1,8 @@
1
+ import {
2
+ Breadcrumb,
3
+ BreadcrumbSeparator,
4
+ BreadcrumbItem,
5
+ BreadcrumbHome,
6
+ } from "./breadcrumb";
7
+
8
+ export { Breadcrumb, BreadcrumbSeparator, BreadcrumbItem, BreadcrumbHome };
@@ -1,8 +1,8 @@
1
+ import { Button } from "@/components/notion-ui/button";
1
2
  import type { Meta, StoryObj } from "@storybook/react";
2
- import Button from "./button";
3
3
 
4
4
  const meta: Meta<typeof Button> = {
5
- title: "Components/Button",
5
+ title: "Button/Button",
6
6
  component: Button,
7
7
  tags: ["autodocs"],
8
8
  argTypes: {
@@ -17,14 +17,14 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
17
17
  variant == "secondary"
18
18
  ? "bg-tertiary hover:shadow-sm shadow-lg hover:bg-tertiary rounded text-[12px] w-fit text-white"
19
19
  : variant == "warning"
20
- ? "bg-red-500 text-primary-foreground"
21
- : variant == "icon"
22
- ? "rtl:px-3 rtl:py-1 ltr:py-2 border gap-x-3 text-primary border border-primary/10 hover:bg-primary/5 hover:opacity-90 transition-opacity px-5"
23
- : variant == "success"
24
- ? "bg-green-500 text-primary-foreground"
25
- : variant == "outline"
26
- ? "text-primary border border-primary/10 hover:bg-primary/5"
27
- : "bg-primary hover:shadow hover:bg-primary shadow shadow-primary/50 text-primary-foreground/80 hover:opacity-90 hover:text-primary-foreground";
20
+ ? "bg-red-500 text-primary-foreground"
21
+ : variant == "icon"
22
+ ? "rtl:px-3 rtl:py-1 ltr:py-2 border gap-x-3 text-primary border border-primary/10 hover:bg-primary/5 hover:opacity-90 transition-opacity px-5"
23
+ : variant == "success"
24
+ ? "bg-green-500 text-primary-foreground"
25
+ : variant == "outline"
26
+ ? "text-primary border border-primary/10 hover:bg-primary/5"
27
+ : "bg-primary hover:shadow hover:bg-primary shadow shadow-primary/50 text-primary-foreground/80 hover:opacity-90 hover:text-primary-foreground";
28
28
  return (
29
29
  <button
30
30
  {...rest}
@@ -36,12 +36,12 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
36
36
  style,
37
37
  disabled &&
38
38
  "opacity-35 pointer-events-none disabled:cursor-not-allowed",
39
- className
39
+ className,
40
40
  )}
41
41
  >
42
42
  {children}
43
43
  </button>
44
44
  );
45
- }
45
+ },
46
46
  );
47
- export default Button;
47
+ export { Button };
@@ -1,3 +1,3 @@
1
- import Button from "./button";
1
+ import { Button } from "./button";
2
2
 
3
- export default Button;
3
+ export { Button };
@@ -1,10 +1,9 @@
1
+ import { Button } from "@/components/notion-ui/button";
2
+ import { ButtonSpinner } from "@/components/notion-ui/button-spinner";
1
3
  import type { Meta, StoryObj } from "@storybook/react";
2
- import React from "react";
3
- import ButtonSpinner from "./button-spinner";
4
- import Button from "../button/button";
5
4
 
6
5
  const meta: Meta<typeof ButtonSpinner> = {
7
- title: "Components/ButtonSpinner",
6
+ title: "Button/ButtonSpinner",
8
7
  component: ButtonSpinner,
9
8
  tags: ["autodocs"],
10
9
  argTypes: {
@@ -1,12 +1,9 @@
1
- import { cn } from "../../utils/cn";
2
- // import { cn } from "@/utils/cn";
3
-
4
- export interface IButtonSpinnerProps {
1
+ interface IButtonSpinnerProps {
5
2
  children: any;
6
3
  loading: boolean;
7
4
  }
8
5
 
9
- export default function ButtonSpinner(props: IButtonSpinnerProps) {
6
+ function ButtonSpinner(props: IButtonSpinnerProps) {
10
7
  const { loading, children } = props;
11
8
  return (
12
9
  <>
@@ -23,3 +20,4 @@ export default function ButtonSpinner(props: IButtonSpinnerProps) {
23
20
  </>
24
21
  );
25
22
  }
23
+ export { ButtonSpinner, type IButtonSpinnerProps };
@@ -1,3 +1,3 @@
1
- import ButtonSpinner from "./button-spinner";
1
+ import { ButtonSpinner, IButtonSpinnerProps } from "./button-spinner";
2
2
 
3
- export default ButtonSpinner;
3
+ export { ButtonSpinner, type IButtonSpinnerProps };
@@ -1,5 +1,5 @@
1
+ import { CachedImage, ImageProps } from "@/components/notion-ui/cached-image";
1
2
  import type { Meta, StoryObj } from "@storybook/react";
2
- import CachedImage, { ImageProps } from "./cached-image";
3
3
 
4
4
  /* ---------------------------------- */
5
5
  /* Helpers */
@@ -52,7 +52,7 @@ type Story = StoryObj<ImageProps>;
52
52
  // Default (fetch + cache)
53
53
  export const Default: Story = {
54
54
  args: {
55
- src: "/images/sample.jpg",
55
+ src: "https://picsum.photos/200",
56
56
  fetch: fetchImage,
57
57
  className: "w-40 h-40 rounded-md",
58
58
  },
@@ -62,7 +62,7 @@ export const Default: Story = {
62
62
  export const WithApiConfig: Story = {
63
63
  args: {
64
64
  apiConfig: {
65
- src: "/images/sample.jpg",
65
+ src: "https://picsum.photos/200",
66
66
  },
67
67
  className: "w-40 h-40 rounded-lg",
68
68
  },
@@ -73,14 +73,14 @@ export const CrossOrigin: Story = {
73
73
  args: {
74
74
  src: "https://picsum.photos/200",
75
75
  fetch: fetchImage,
76
- className: "w-40 h-40 rounded-full",
76
+ className: "w-40 h-40 rounded-full border/10",
77
77
  },
78
78
  };
79
79
 
80
80
  // Loading / shimmer state
81
81
  export const LoadingState: Story = {
82
82
  args: {
83
- src: "/images/sample.jpg",
83
+ src: "https://picsum.photos/200",
84
84
  fetch: delayedFetch,
85
85
  className: "w-32 h-32",
86
86
  classNames: {
@@ -93,7 +93,7 @@ export const LoadingState: Story = {
93
93
  // Small size
94
94
  export const Small: Story = {
95
95
  args: {
96
- src: "/images/sample.jpg",
96
+ src: "https://picsum.photos/200",
97
97
  fetch: fetchImage,
98
98
  className: "w-16 h-16 rounded",
99
99
  },
@@ -102,7 +102,7 @@ export const Small: Story = {
102
102
  // Large size
103
103
  export const Large: Story = {
104
104
  args: {
105
- src: "/images/sample.jpg",
105
+ src: "https://picsum.photos/200",
106
106
  fetch: fetchImage,
107
107
  className: "w-64 h-64 rounded-xl",
108
108
  },
@@ -1,4 +1,4 @@
1
- import Shimmer from "@/components/notion-ui/shimmer";
1
+ import { Shimmer } from "@/components/notion-ui/shimmer";
2
2
  import { cn } from "@/utils/cn";
3
3
  import React, { useEffect, useState, forwardRef, useRef } from "react";
4
4
 
@@ -31,7 +31,7 @@ interface ApiConfigImageProps extends BaseImageProps {
31
31
  fetch?: never;
32
32
  }
33
33
 
34
- export type ImageProps = FetchImageProps | ApiConfigImageProps;
34
+ type ImageProps = FetchImageProps | ApiConfigImageProps;
35
35
 
36
36
  /* ---------------------------------- */
37
37
  /* Cache helpers */
@@ -40,6 +40,10 @@ export type ImageProps = FetchImageProps | ApiConfigImageProps;
40
40
  const IMAGE_CACHE = "image-cache-v1";
41
41
 
42
42
  async function getCachedImage(url: string): Promise<string | null> {
43
+ if (typeof window === "undefined" || typeof caches === "undefined") {
44
+ return null;
45
+ }
46
+
43
47
  const cache = await caches.open(IMAGE_CACHE);
44
48
  const cached = await cache.match(url);
45
49
 
@@ -50,6 +54,9 @@ async function getCachedImage(url: string): Promise<string | null> {
50
54
  }
51
55
 
52
56
  async function cacheImage(url: string, response: Response) {
57
+ if (typeof window === "undefined" || typeof caches === "undefined") {
58
+ return;
59
+ }
53
60
  const cache = await caches.open(IMAGE_CACHE);
54
61
  await cache.put(url, response.clone());
55
62
  }
@@ -75,6 +82,21 @@ const CachedImage = forwardRef<HTMLDivElement, ImageProps>((props, ref) => {
75
82
 
76
83
  const [imageUrl, setImageUrl] = useState<string | null>(null);
77
84
  const [loading, setLoading] = useState(true);
85
+ const previousUrlRef = useRef<string | null>(null);
86
+
87
+ useEffect(() => {
88
+ if (previousUrlRef.current?.startsWith("blob:")) {
89
+ URL.revokeObjectURL(previousUrlRef.current);
90
+ }
91
+
92
+ previousUrlRef.current = imageUrl;
93
+
94
+ return () => {
95
+ if (previousUrlRef.current?.startsWith("blob:")) {
96
+ URL.revokeObjectURL(previousUrlRef.current);
97
+ }
98
+ };
99
+ }, [imageUrl]);
78
100
 
79
101
  const { shimmerClassName, shimmerIconClassName } = classNames || {};
80
102
 
@@ -83,7 +105,7 @@ const CachedImage = forwardRef<HTMLDivElement, ImageProps>((props, ref) => {
83
105
  fetchRef.current = fetch;
84
106
  }, [fetch]);
85
107
 
86
- async function loadImage() {
108
+ async function loadImage(signal: AbortSignal) {
87
109
  try {
88
110
  const resolvedSrc = apiConfig?.src ?? src;
89
111
 
@@ -106,8 +128,10 @@ const CachedImage = forwardRef<HTMLDivElement, ImageProps>((props, ref) => {
106
128
  /* ---------------------------------- */
107
129
  const cached = await getCachedImage(resolvedSrc);
108
130
  if (cached) {
109
- setImageUrl(cached);
110
- setLoading(false);
131
+ if (!signal.aborted) {
132
+ setImageUrl(cached);
133
+ setLoading(false);
134
+ }
111
135
  return;
112
136
  }
113
137
 
@@ -118,7 +142,9 @@ const CachedImage = forwardRef<HTMLDivElement, ImageProps>((props, ref) => {
118
142
  ? await fetchRef.current(resolvedSrc)
119
143
  : await window.fetch(resolvedSrc, {
120
144
  headers: apiConfig?.headers,
145
+ signal,
121
146
  });
147
+ if (signal.aborted) return;
122
148
 
123
149
  const contentType = response.headers.get("content-type") ?? "";
124
150
 
@@ -137,38 +163,35 @@ const CachedImage = forwardRef<HTMLDivElement, ImageProps>((props, ref) => {
137
163
  } catch (err) {
138
164
  console.error(err);
139
165
  } finally {
140
- setLoading(false);
166
+ if (!signal.aborted) {
167
+ setLoading(false);
168
+ }
141
169
  }
142
170
  }
143
171
 
144
172
  useEffect(() => {
145
- loadImage();
146
- }, [src, apiConfig?.src]);
173
+ const controller = new AbortController();
174
+ setLoading(true);
147
175
 
148
- /* ---------------------------------- */
149
- /* Cleanup */
150
- /* ---------------------------------- */
176
+ loadImage(controller.signal);
151
177
 
152
- useEffect(() => {
153
178
  return () => {
154
- if (imageUrl?.startsWith("blob:")) {
155
- URL.revokeObjectURL(imageUrl);
156
- }
179
+ controller.abort();
157
180
  };
158
- }, [imageUrl]);
181
+ }, [src, apiConfig?.src]);
159
182
 
160
183
  /* ---------------------------------- */
161
184
  /* UI */
162
185
  /* ---------------------------------- */
163
186
 
164
187
  if (loading || !imageUrl) {
165
- const stop = loading ? false : !imageUrl && true;
188
+ const stop = !loading && !imageUrl;
166
189
 
167
190
  return (
168
191
  <Shimmer
169
192
  className={cn(
170
193
  "bg-primary/10 mx-auto flex p-2 items-center size-8 rounded border border-tertiary/10",
171
- shimmerClassName
194
+ shimmerClassName,
172
195
  )}
173
196
  stop={stop}
174
197
  >
@@ -182,7 +205,7 @@ const CachedImage = forwardRef<HTMLDivElement, ImageProps>((props, ref) => {
182
205
  strokeLinejoin="round"
183
206
  className={cn(
184
207
  "stroke-primary/40 mx-auto stroke-2",
185
- shimmerIconClassName
208
+ shimmerIconClassName,
186
209
  )}
187
210
  >
188
211
  <rect x="1" y="1" width="22" height="22" rx="2" ry="2" />
@@ -196,12 +219,10 @@ const CachedImage = forwardRef<HTMLDivElement, ImageProps>((props, ref) => {
196
219
  return (
197
220
  <div
198
221
  ref={ref}
199
- // src={imageUrl}
200
222
  style={{ backgroundImage: `url(${imageUrl})` }}
201
- // alt={alt}
202
223
  className={cn(
203
224
  "cursor-pointer shadow-lg bg-cover bg-center mx-auto",
204
- className
225
+ className,
205
226
  )}
206
227
  {...imgProps}
207
228
  />
@@ -210,4 +231,4 @@ const CachedImage = forwardRef<HTMLDivElement, ImageProps>((props, ref) => {
210
231
 
211
232
  CachedImage.displayName = "CachedImage";
212
233
 
213
- export default CachedImage;
234
+ export { CachedImage, type ImageProps };
@@ -1,3 +1,3 @@
1
- import CachedImage from "./cached-image";
1
+ import { ImageProps, CachedImage } from "./cached-image";
2
2
 
3
- export default CachedImage;
3
+ export { CachedImage, type ImageProps };
@@ -1,7 +1,6 @@
1
+ import { CachedSvg } from "@/components/notion-ui/cached-svg";
1
2
  import type { Meta, StoryObj } from "@storybook/react";
2
3
 
3
- import CachedSvg from "./cached-svg";
4
-
5
4
  /* ---------------------------------- */
6
5
  /* Meta */
7
6
  /* ---------------------------------- */
@@ -36,11 +35,10 @@ const sampleSvg = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
36
35
  /* -------- Fetch-based example -------- */
37
36
  export const FetchExample: Story = {
38
37
  render: () => (
39
- <div style={{ width: 40, height: 40 }}>
38
+ <div>
40
39
  <CachedSvg
41
- src="https://dummy-svg-url.com/sample.svg"
42
- fetch={async () => {
43
- return new Response(sampleSvg, { status: 200 });
40
+ apiConfig={{
41
+ src: "https://cdn.jsdelivr.net/gh/hampusborgos/country-flags@main/svg/af.svg",
44
42
  }}
45
43
  />
46
44
  </div>
@@ -50,12 +48,12 @@ export const FetchExample: Story = {
50
48
  /* -------- API-config-based example -------- */
51
49
  export const ApiConfigExample: Story = {
52
50
  render: () => (
53
- <div style={{ width: 40, height: 40 }}>
51
+ <div>
54
52
  <CachedSvg
55
53
  apiConfig={{
56
- src: "https://dummy-api.com/svg",
57
- headers: { Authorization: "Bearer token" },
54
+ src: "https://cdn.jsdelivr.net/gh/hampusborgos/country-flags@main/svg/af.svg",
58
55
  }}
56
+ className="size-32"
59
57
  />
60
58
  </div>
61
59
  ),
@@ -64,9 +62,9 @@ export const ApiConfigExample: Story = {
64
62
  /* -------- Loading state (Shimmer) -------- */
65
63
  export const LoadingState: Story = {
66
64
  render: () => (
67
- <div style={{ width: 40, height: 40 }}>
65
+ <div>
68
66
  <CachedSvg
69
- src="https://dummy-loading.com/svg"
67
+ src="https://cdn.jsdelivr.net/gh/hampusborgos/country-flags@main/svg/af.svg"
70
68
  fetch={() => new Promise(() => {})} // never resolves to simulate loading
71
69
  />
72
70
  </div>