notionsoft-ui 1.0.33 → 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
|
@@ -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 &&
|
|
@@ -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;
|