notionsoft-ui 1.0.32 → 1.0.34

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notionsoft-ui",
3
- "version": "1.0.32",
3
+ "version": "1.0.34",
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,7 +24,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
24
24
  disabled={disabled}
25
25
  ref={ref}
26
26
  className={cn(
27
- `rounded-sm flex items-center justify-center gap-x-1 cursor-pointer font-medium ltr:text-xs leading-snug li rtl:text-[13px] sm:rtl:text-sm rtl:font-semibold
27
+ `rounded-sm grid grid-cols-[1fr_1fr_auto] leading-snug cursor-pointer font-medium ltr:text-xs rtl:text-[13px] sm:rtl:text-sm rtl:font-semibold
28
28
  transition w-fit px-3 py-1.5 duration-200 ease-linear`,
29
29
  style,
30
30
  disabled &&
@@ -410,7 +410,7 @@ function MultiSelectInputInner<T = any>(
410
410
  setShowSelectedOnly(true); // Show only selected items
411
411
  updatePosition(); // Recalculate dropdown position
412
412
  }}
413
- className="flex items-center hover:bg-tertiary/10 hover:text-tertiary cursor-pointer text-primary/60 rounded transition-colors"
413
+ className="flex items-center pointer-events-auto hover:bg-tertiary/10 hover:text-tertiary cursor-pointer text-primary/60 rounded transition-colors"
414
414
  >
415
415
  <List className="size-[38px] p-3" />
416
416
  <span className="text-sm px-1">{selectedItems.length}</span>
@@ -555,7 +555,7 @@ const Dropdown = <T,>(
555
555
  "focus-visible:border-tertiary/60",
556
556
  "[&::-webkit-outer-spin-button]:appearance-none",
557
557
  "[&::-webkit-inner-spin-button]:appearance-none",
558
- "[-moz-appearance:textfield] "
558
+ "[-moz-appearance:textfield]"
559
559
  )}
560
560
  placeholder={text.maxRecord}
561
561
  />
@@ -102,26 +102,55 @@ const MultiTabInput = React.forwardRef<HTMLInputElement, MultiTabInputProps>(
102
102
  tabs.map((tab, idx) => {
103
103
  const tabName = tab.props.children;
104
104
  const state: TabState = getTabState(tabName, optional);
105
+ const tabHasError = hasError(tabName);
105
106
 
106
107
  return React.cloneElement(tab, {
107
108
  key: `${optional ? "opt" : "mand"}-${idx}`,
108
109
  state,
109
110
  optional,
110
111
  onClick: () => handleTabChange(tabName, optional),
111
- className: tab.props.className,
112
+ className: cn(
113
+ tab.props.className,
114
+ tabHasError && "text-red-400 border-red-400"
115
+ ),
112
116
  });
113
117
  });
114
118
 
119
+ const hasError = (tabKey: string) => {
120
+ if (!errorData) return false;
121
+ return errorData.has(generateUniqueName(name, tabKey));
122
+ };
115
123
  const activeTabName = generateUniqueName(name, tabState.active);
116
124
  const selectTabValue = tabData[activeTabName] || "";
117
- const errorMessages = errorData?.get(activeTabName)
118
- ? [errorData.get(activeTabName)!]
119
- : [];
120
125
 
121
126
  const direction =
122
127
  activeTabName.endsWith("farsi") || activeTabName.endsWith("pashto")
123
128
  ? "rtl"
124
129
  : "ltr";
130
+
131
+ const errorMessage = useMemo(() => {
132
+ if (!errorData) return null;
133
+
134
+ return Array.from(errorData.entries())
135
+ .filter(([key]) => key.startsWith(`${name}_`))
136
+ .map(([key, value], index) => (
137
+ <AnimatedItem
138
+ key={key}
139
+ springProps={{
140
+ from: { opacity: 0, transform: "translateY(-8px)" },
141
+ to: { opacity: 1, transform: "translateY(0px)" },
142
+ delay: index * 100,
143
+ config: { mass: 1, tension: 210, friction: 20 },
144
+ }}
145
+ intersectionArgs={{ once: true, rootMargin: "-5% 0%" }}
146
+ >
147
+ <h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-[11px]">
148
+ {value}
149
+ </h1>
150
+ </AnimatedItem>
151
+ ));
152
+ }, [errorData, name]);
153
+
125
154
  return (
126
155
  <div className={cn("flex flex-col select-none", rootDivClassName)}>
127
156
  <div className="flex flex-col-reverse sm:flex-row sm:justify-between sm:items-end gap-4">
@@ -157,31 +186,15 @@ const MultiTabInput = React.forwardRef<HTMLInputElement, MultiTabInputProps>(
157
186
  placeholder={placeholder}
158
187
  onChange={handleInputChange}
159
188
  className={cn(
160
- `mt-2 ${errorMessages.length > 0 && "border-red-400 border-b!"}`,
189
+ `mt-2 ${
190
+ errorMessage &&
191
+ errorMessage.length > 0 &&
192
+ "border-red-400 border-b!"
193
+ }`,
161
194
  className
162
195
  )}
163
196
  />
164
-
165
- {errorMessages.map((error: string, index) => (
166
- <AnimatedItem
167
- key={index}
168
- springProps={{
169
- from: { opacity: 0, transform: "translateY(-8px)" },
170
- to: {
171
- opacity: 1,
172
- transform: "translateY(0px)",
173
- delay: index * 100,
174
- },
175
- config: { mass: 1, tension: 210, friction: 20 },
176
- delay: index * 100,
177
- }}
178
- intersectionArgs={{ once: true, rootMargin: "-5% 0%" }}
179
- >
180
- <h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-[11px]">
181
- {error}
182
- </h1>
183
- </AnimatedItem>
184
- ))}
197
+ {errorMessage}
185
198
  </div>
186
199
  );
187
200
  }
@@ -101,26 +101,53 @@ const MultiTabTextarea = React.forwardRef<
101
101
  tabs.map((tab, idx) => {
102
102
  const tabName = tab.props.children;
103
103
  const state: TabState = getTabState(tabName, optional);
104
+ const tabHasError = hasError(tabName);
104
105
 
105
106
  return React.cloneElement(tab, {
106
107
  key: `${optional ? "opt" : "mand"}-${idx}`,
107
108
  state,
108
109
  optional,
109
110
  onClick: () => handleTabChange(tabName, optional),
110
- className: tab.props.className,
111
+ className: cn(
112
+ tab.props.className,
113
+ tabHasError && "text-red-400 border-red-400"
114
+ ),
111
115
  });
112
116
  });
113
-
117
+ const hasError = (tabKey: string) => {
118
+ if (!errorData) return false;
119
+ return errorData.has(generateUniqueName(name, tabKey));
120
+ };
114
121
  const activeTabName = generateUniqueName(name, tabState.active);
115
122
  const selectTabValue = tabData[activeTabName] || "";
116
- const errorMessages = errorData?.get(activeTabName)
117
- ? [errorData.get(activeTabName)!]
118
- : [];
119
123
 
120
124
  const direction =
121
125
  activeTabName.endsWith("farsi") || activeTabName.endsWith("pashto")
122
126
  ? "rtl"
123
127
  : "ltr";
128
+ const errorMessage = useMemo(() => {
129
+ if (!errorData) return null;
130
+
131
+ return Array.from(errorData.entries())
132
+ .filter(([key]) => key.startsWith(`${name}_`))
133
+ .map(([key, value], index) => (
134
+ <AnimatedItem
135
+ key={key}
136
+ springProps={{
137
+ from: { opacity: 0, transform: "translateY(-8px)" },
138
+ to: { opacity: 1, transform: "translateY(0px)" },
139
+ delay: index * 100,
140
+ config: { mass: 1, tension: 210, friction: 20 },
141
+ }}
142
+ intersectionArgs={{ once: true, rootMargin: "-5% 0%" }}
143
+ >
144
+ <h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-[11px]">
145
+ {value}
146
+ </h1>
147
+ </AnimatedItem>
148
+ ));
149
+ }, [errorData, name]);
150
+
124
151
  return (
125
152
  <div className={cn("flex flex-col select-none", rootDivClassName)}>
126
153
  <div className="flex flex-col-reverse sm:flex-row sm:justify-between sm:items-end gap-4">
@@ -152,31 +179,16 @@ const MultiTabTextarea = React.forwardRef<
152
179
  placeholder={placeholder}
153
180
  onChange={handleInputChange}
154
181
  className={cn(
155
- `mt-2 ${errorMessages.length > 0 ? "border-red-400 border-b!" : ""}`,
182
+ `mt-2 ${
183
+ errorMessage &&
184
+ errorMessage.length > 0 &&
185
+ "border-red-400 border-b!"
186
+ }`,
156
187
  className
157
188
  )}
158
189
  />
159
190
 
160
- {errorMessages.map((error: string, index) => (
161
- <AnimatedItem
162
- key={index}
163
- springProps={{
164
- from: { opacity: 0, transform: "translateY(-8px)" },
165
- to: {
166
- opacity: 1,
167
- transform: "translateY(0px)",
168
- delay: index * 100,
169
- },
170
- config: { mass: 1, tension: 210, friction: 20 },
171
- delay: index * 100,
172
- }}
173
- intersectionArgs={{ once: true, rootMargin: "-5% 0%" }}
174
- >
175
- <h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-[11px]">
176
- {error}
177
- </h1>
178
- </AnimatedItem>
179
- ))}
191
+ {errorMessage}
180
192
  </div>
181
193
  );
182
194
  });
@@ -0,0 +1,3 @@
1
+ import PageSizeSelect from "./page-size-select";
2
+
3
+ export default PageSizeSelect;
@@ -0,0 +1,117 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import PageSizeSelect from "./page-size-select";
4
+
5
+ const meta: Meta<typeof PageSizeSelect> = {
6
+ title: "Select/PageSizeSelect",
7
+ component: PageSizeSelect,
8
+ parameters: {
9
+ layout: "centered",
10
+ docs: {
11
+ description: {
12
+ component: `
13
+ A pagination page-size selector with:
14
+ - Preset options
15
+ - Custom numeric input
16
+ - LocalStorage persistence (or custom save/load)
17
+ - Smart dropdown positioning (up/down)
18
+ `,
19
+ },
20
+ },
21
+ },
22
+ argTypes: {
23
+ onChange: { action: "changed" },
24
+ },
25
+ };
26
+
27
+ export default meta;
28
+
29
+ type Story = StoryObj<typeof PageSizeSelect>;
30
+
31
+ const OPTIONS = [
32
+ { value: "10", label: "10 / page" },
33
+ { value: "20", label: "20 / page" },
34
+ { value: "50", label: "50 / page" },
35
+ { value: "100", label: "100 / page" },
36
+ ];
37
+
38
+ /* ---------------------------------------------
39
+ Default
40
+ --------------------------------------------- */
41
+ export const Default: Story = {
42
+ args: {
43
+ placeholder: "Select page size",
44
+ emptyPlaceholder: "No options",
45
+ rangePlaceholder: "Custom size",
46
+ paginationKey: "storybook-page-size",
47
+ options: OPTIONS,
48
+ },
49
+ };
50
+
51
+ /* ---------------------------------------------
52
+ With State Preview
53
+ --------------------------------------------- */
54
+ export const WithState: Story = {
55
+ render: (args) => {
56
+ const [value, setValue] = useState<string>("");
57
+
58
+ return (
59
+ <div className="w-64 space-y-3">
60
+ <PageSizeSelect
61
+ {...args}
62
+ onChange={(v) => {
63
+ setValue(v);
64
+ args.onChange?.(v);
65
+ }}
66
+ />
67
+
68
+ <div className="text-sm text-muted-foreground">
69
+ Selected value: <strong>{value || "-"}</strong>
70
+ </div>
71
+ </div>
72
+ );
73
+ },
74
+ args: {
75
+ placeholder: "Page size",
76
+ emptyPlaceholder: "No options available",
77
+ rangePlaceholder: "Enter number",
78
+ paginationKey: "storybook-with-state",
79
+ options: OPTIONS,
80
+ },
81
+ };
82
+
83
+ /* ---------------------------------------------
84
+ Empty Options
85
+ --------------------------------------------- */
86
+ export const EmptyOptions: Story = {
87
+ args: {
88
+ placeholder: "Page size",
89
+ emptyPlaceholder: "Nothing to show",
90
+ rangePlaceholder: "Enter number",
91
+ paginationKey: "storybook-empty",
92
+ options: [],
93
+ },
94
+ };
95
+
96
+ /* ---------------------------------------------
97
+ Custom Storage (Mock)
98
+ --------------------------------------------- */
99
+ export const CustomStorage: Story = {
100
+ args: {
101
+ placeholder: "Page size",
102
+ emptyPlaceholder: "No data",
103
+ rangePlaceholder: "Custom size",
104
+ paginationKey: "storybook-custom-storage",
105
+ options: OPTIONS,
106
+ save: async (key, data) => {
107
+ console.log("Saved:", key, data);
108
+ },
109
+ load: async () => {
110
+ return {
111
+ key: "storybook-custom-storage",
112
+ value: "20",
113
+ option: 1,
114
+ };
115
+ },
116
+ },
117
+ };
@@ -0,0 +1,266 @@
1
+ import { cn } from "../../utils/cn";
2
+ import { Check, ChevronDown } from "lucide-react";
3
+ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+
6
+ interface Option {
7
+ value: string;
8
+ label: string;
9
+ }
10
+
11
+ interface SelectProps {
12
+ placeholder: string;
13
+ className?: string;
14
+ paginationKey: string;
15
+ emptyPlaceholder: string;
16
+ rangePlaceholder: string;
17
+ options: Option[];
18
+ onChange?: (value: string) => void;
19
+
20
+ save?: (key: string, data: any) => Promise<void> | void;
21
+ load?: (key: string) => Promise<any> | any;
22
+ }
23
+
24
+ const KEYS = {
25
+ input: 0,
26
+ default: 1,
27
+ };
28
+
29
+ // ---------------- Default Storage ----------------
30
+ const defaultSave = (key: string, data: any, STORAGE_KEY: string) => {
31
+ try {
32
+ localStorage.setItem(STORAGE_KEY + key, JSON.stringify(data));
33
+ } catch {}
34
+ };
35
+
36
+ const defaultLoad = (key: string, STORAGE_KEY: string) => {
37
+ try {
38
+ const raw = localStorage.getItem(STORAGE_KEY + key);
39
+ return raw ? JSON.parse(raw) : null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ };
44
+
45
+ const PageSizeSelect: React.FC<SelectProps> = ({
46
+ placeholder,
47
+ emptyPlaceholder,
48
+ rangePlaceholder,
49
+ options,
50
+ onChange,
51
+ className,
52
+ paginationKey,
53
+ save,
54
+ load,
55
+ }) => {
56
+ const [mounted, setMounted] = useState(false);
57
+ const [dropDirection, setDropDirection] = useState<"up" | "down">("down");
58
+ const [position, setPosition] = useState({
59
+ top: 0,
60
+ left: 0,
61
+ width: 0,
62
+ });
63
+
64
+ const [selectData, setSelectData] = useState({
65
+ isOpen: false,
66
+ showIcon: false,
67
+ select: { key: "", value: "", option: -1 },
68
+ });
69
+
70
+ const selectRef = useRef<HTMLDivElement>(null);
71
+ const dropdownRef = useRef<HTMLDivElement>(null);
72
+ const inputRef = useRef<HTMLInputElement>(null);
73
+
74
+ const saveFn = save
75
+ ? save
76
+ : (key: string, data: any) => defaultSave(key, data, paginationKey);
77
+
78
+ const loadFn = load ? load : (key: string) => defaultLoad(key, paginationKey);
79
+
80
+ // ---------------- Mount ----------------
81
+ useEffect(() => {
82
+ setMounted(true);
83
+ }, []);
84
+
85
+ // ---------------- Load Cache ----------------
86
+ useEffect(() => {
87
+ const loadCache = async () => {
88
+ const cached = await loadFn(paginationKey);
89
+ if (cached) {
90
+ setSelectData((p) => ({ ...p, select: cached }));
91
+ onChange?.(cached.value);
92
+ } else {
93
+ const item = { key: paginationKey, value: "10", option: KEYS.default };
94
+ setSelectData((p) => ({ ...p, select: item }));
95
+ saveFn(paginationKey, item);
96
+ onChange?.("10");
97
+ }
98
+ };
99
+ loadCache();
100
+ }, [paginationKey]);
101
+
102
+ // ---------------- Positioning ----------------
103
+ const updatePosition = () => {
104
+ const trigger = selectRef.current;
105
+ const dropdown = dropdownRef.current;
106
+ if (!trigger || !dropdown) return;
107
+
108
+ const rect = trigger.getBoundingClientRect();
109
+ const viewportHeight = window.innerHeight;
110
+ const gap = 6;
111
+
112
+ const dropdownHeight = Math.min(dropdown.offsetHeight || 0, 260);
113
+ const spaceBelow = viewportHeight - rect.bottom;
114
+ const spaceAbove = rect.top;
115
+
116
+ if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) {
117
+ setDropDirection("up");
118
+ setPosition({
119
+ top: rect.top + window.scrollY - dropdownHeight - gap,
120
+ left: rect.left + window.scrollX,
121
+ width: rect.width,
122
+ });
123
+ } else {
124
+ setDropDirection("down");
125
+ setPosition({
126
+ top: rect.bottom + window.scrollY + gap,
127
+ left: rect.left + window.scrollX,
128
+ width: rect.width,
129
+ });
130
+ }
131
+ };
132
+
133
+ useLayoutEffect(() => {
134
+ if (selectData.isOpen) updatePosition();
135
+ }, [selectData.isOpen, options.length]);
136
+
137
+ useEffect(() => {
138
+ if (!selectData.isOpen) return;
139
+ window.addEventListener("resize", updatePosition);
140
+ window.addEventListener("scroll", updatePosition, true);
141
+ return () => {
142
+ window.removeEventListener("resize", updatePosition);
143
+ window.removeEventListener("scroll", updatePosition, true);
144
+ };
145
+ }, [selectData.isOpen]);
146
+
147
+ // ---------------- Outside Click ----------------
148
+ useEffect(() => {
149
+ const handler = (e: MouseEvent) => {
150
+ if (
151
+ !selectRef.current?.contains(e.target as Node) &&
152
+ !dropdownRef.current?.contains(e.target as Node)
153
+ ) {
154
+ setSelectData((p) => ({ ...p, isOpen: false, showIcon: false }));
155
+ }
156
+ };
157
+ document.addEventListener("mousedown", handler);
158
+ return () => document.removeEventListener("mousedown", handler);
159
+ }, []);
160
+
161
+ // ---------------- Select ----------------
162
+ const handleSelect = async (value: string) => {
163
+ const item = { key: paginationKey, value, option: KEYS.default };
164
+ onChange?.(value);
165
+ setSelectData((p) => ({ ...p, isOpen: false, select: item }));
166
+ await saveFn(paginationKey, item);
167
+ };
168
+
169
+ // ---------------- Render ----------------
170
+ return (
171
+ <div ref={selectRef} className={cn("w-full", className)}>
172
+ <button
173
+ onClick={() => setSelectData((p) => ({ ...p, isOpen: !p.isOpen }))}
174
+ className="w-full py-2 border rounded-md flex items-center justify-between bg-card"
175
+ >
176
+ {selectData.select.value || placeholder}
177
+ <ChevronDown
178
+ className={cn(
179
+ "size-3 transition-transform",
180
+ selectData.isOpen && "rotate-180"
181
+ )}
182
+ />
183
+ </button>
184
+
185
+ {mounted &&
186
+ selectData.isOpen &&
187
+ createPortal(
188
+ <div
189
+ ref={dropdownRef}
190
+ className={cn(
191
+ "absolute min-w-fit z-50 bg-card border border-primary/15 shadow-lg",
192
+ dropDirection === "down" ? "rounded-b-md" : "rounded-t-md"
193
+ )}
194
+ style={{
195
+ top: position.top,
196
+ left: position.left,
197
+ width: position.width,
198
+ }}
199
+ >
200
+ {/* Input */}
201
+ <div className="relative">
202
+ <input
203
+ ref={inputRef}
204
+ type="number"
205
+ placeholder={rangePlaceholder}
206
+ onFocus={() => setSelectData((p) => ({ ...p, showIcon: true }))}
207
+ defaultValue={
208
+ selectData.select.option === KEYS.input
209
+ ? selectData.select.value
210
+ : ""
211
+ }
212
+ className={`bg-card dark:bg-card-secondary text-tertiary rtl:text-lg-rtl w-full [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none text-center text-sm px-4 py-2 border-b border-primary/15 rounded-t-md focus:outline-none`}
213
+ />
214
+ <Check
215
+ className={cn(
216
+ "size-4 absolute top-2.5 right-2 cursor-pointer",
217
+ !selectData.showIcon && "hidden"
218
+ )}
219
+ onClick={async () => {
220
+ const value = inputRef.current?.value || "10";
221
+ const option = value ? KEYS.input : KEYS.default;
222
+ const item = { key: paginationKey, value, option };
223
+ onChange?.(value);
224
+ await saveFn(paginationKey, item);
225
+ setSelectData((p) => ({
226
+ ...p,
227
+ isOpen: false,
228
+ showIcon: false,
229
+ select: item,
230
+ }));
231
+ }}
232
+ />
233
+ </div>
234
+
235
+ {/* Options */}
236
+ <ul className="max-h-60 overflow-auto">
237
+ {options.length === 0 ? (
238
+ <li className="px-4 py-2 text-center text-sm">
239
+ {emptyPlaceholder}
240
+ </li>
241
+ ) : (
242
+ options.map((o) => (
243
+ <li
244
+ key={o.value}
245
+ onClick={() => handleSelect(o.value)}
246
+ className={cn(
247
+ "px-4 py-2 cursor-pointer flex justify-between hover:bg-primary/10",
248
+ selectData.select.value === o.value && "bg-primary/10"
249
+ )}
250
+ >
251
+ {o.label}
252
+ {selectData.select.value === o.value && (
253
+ <Check className="size-3" />
254
+ )}
255
+ </li>
256
+ ))
257
+ )}
258
+ </ul>
259
+ </div>,
260
+ document.body
261
+ )}
262
+ </div>
263
+ );
264
+ };
265
+
266
+ export default PageSizeSelect;
@@ -0,0 +1,3 @@
1
+ import Shimmer from "./shimmer";
2
+
3
+ export default Shimmer;
@@ -0,0 +1,25 @@
1
+ import React from "react";
2
+ import { Meta, StoryObj } from "@storybook/react";
3
+ import Shimmer, { ShimmerItem, ShimmerProps } from "./shimmer";
4
+
5
+ const meta: Meta<typeof Shimmer> = {
6
+ title: "Shimmer/Shimmer",
7
+ component: Shimmer,
8
+ tags: ["autodocs"],
9
+ };
10
+
11
+ export default meta;
12
+
13
+ type Story = StoryObj<typeof Shimmer>;
14
+
15
+ export const Default: Story = {
16
+ render: (args: ShimmerProps) => (
17
+ <div className="space-y-2 p-4 w-96">
18
+ <Shimmer {...args}>
19
+ <ShimmerItem className="w-full" />
20
+ <ShimmerItem className="w-3/4" />
21
+ <ShimmerItem className="w-1/2" />
22
+ </Shimmer>
23
+ </div>
24
+ ),
25
+ };
@@ -0,0 +1,47 @@
1
+ import { cn } from "../../utils/cn";
2
+
3
+ export interface ShimmerProps extends React.HTMLAttributes<HTMLDivElement> {}
4
+
5
+ export default function Shimmer({ className, children }: ShimmerProps) {
6
+ return (
7
+ <div
8
+ className={cn("relative w-full overflow-hidden *:rounded-sm", className)}
9
+ >
10
+ {/* Scoped CSS */}
11
+ <style>{`
12
+ @keyframes shimmer {
13
+ 0% {
14
+ background-position: -1200px 0;
15
+ }
16
+ 100% {
17
+ background-position: 1200px 0;
18
+ }
19
+ }
20
+ `}</style>
21
+
22
+ {/* Shimmer overlay */}
23
+ <div
24
+ className="absolute inset-0 pointer-events-none"
25
+ style={{
26
+ backgroundImage: `linear-gradient(
27
+ to right,
28
+ var(--from-shimmer) 10%,
29
+ var(--to-shimmer) 18%,
30
+ var(--from-shimmer) 25%
31
+ )`,
32
+ backgroundSize: "1200px 100%",
33
+ animation: "shimmer 2.2s linear infinite",
34
+ }}
35
+ />
36
+
37
+ {children}
38
+ </div>
39
+ );
40
+ }
41
+ export interface ShimmerItemProps
42
+ extends React.HTMLAttributes<HTMLDivElement> {}
43
+
44
+ export function ShimmerItem(props: ShimmerItemProps) {
45
+ const { className } = props;
46
+ return <div className={cn(`h-10 bg-primary/5`, className)} />;
47
+ }