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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notionsoft-ui",
3
- "version": "1.0.33",
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 &&
@@ -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;