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 +1 -1
- package/src/notion-ui/button/button.tsx +1 -1
- package/src/notion-ui/multi-select-input/multi-select-input.tsx +2 -2
- package/src/notion-ui/multi-tab-input/multi-tab-input.tsx +39 -26
- package/src/notion-ui/multi-tab-textarea/multi-tab-textarea.tsx +38 -26
- package/src/notion-ui/page-size-select/index.ts +3 -0
- package/src/notion-ui/page-size-select/page-size-select.stories.tsx +117 -0
- package/src/notion-ui/page-size-select/page-size-select.tsx +266 -0
- package/src/notion-ui/shimmer/index.ts +3 -0
- package/src/notion-ui/shimmer/shimmer.stories.tsx +25 -0
- package/src/notion-ui/shimmer/shimmer.tsx +47 -0
package/package.json
CHANGED
|
@@ -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
|
|
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:
|
|
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 ${
|
|
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:
|
|
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 ${
|
|
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
|
-
{
|
|
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,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,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
|
+
}
|