@lateralus-ai/shipping-ui 1.4.12 → 1.4.14
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/README.md +108 -108
- package/dist/index.cjs +38 -38
- package/dist/index.esm.js +4936 -4913
- package/dist/tailwind-theme.d.ts +12 -0
- package/package.json +99 -99
- package/src/components/DocumentEditor/DocumentEditor.tsx +871 -871
- package/src/components/HelloWorld.tsx +3 -3
- package/src/components/InputPrompt.tsx +96 -96
- package/src/components/ModalPanel.tsx +49 -49
- package/src/components/PdfViewer/ImageViewer.tsx +211 -211
- package/src/components/PdfViewer/PdfViewer.tsx +232 -209
- package/src/components/PdfViewer/index.ts +2 -2
- package/src/components/PdfViewer/usePageManagement.ts +32 -32
- package/src/components/PdfViewer/usePanning.ts +42 -42
- package/src/components/PdfViewer/useRefDimensions.ts +16 -16
- package/src/components/PdfViewer/useRotation.ts +13 -13
- package/src/components/PdfViewer/useZoom.ts +26 -26
- package/src/components/SearchModal.tsx +320 -320
- package/src/components/Sidebar/Button.tsx +20 -20
- package/src/components/Sidebar/Container.tsx +31 -31
- package/src/components/Sidebar/Item.tsx +39 -39
- package/src/components/Sidebar/Layout.tsx +32 -32
- package/src/components/Sidebar/Provider.tsx +47 -47
- package/src/components/Sidebar/SecondaryItem.tsx +39 -39
- package/src/components/Sidebar/SideContainer.tsx +31 -31
- package/src/components/Sidebar/ToggleCollapseButton.tsx +24 -24
- package/src/components/Sidebar/index.ts +8 -8
- package/src/components/Tabs.tsx +43 -43
- package/src/components/icons/CloseSidebarIcon.tsx +19 -19
- package/src/components/icons/CloseSidebarMidIcon.tsx +19 -19
- package/src/components/icons/ExpandIcon.tsx +21 -21
- package/src/components/icons/SendArrowIcon.tsx +23 -23
- package/src/components/icons/SendArrowIconGreen.tsx +17 -17
- package/src/components/icons/SettingsIcon.tsx +33 -33
- package/src/components/icons/XIcon.tsx +21 -21
- package/src/components/index.ts +6 -6
- package/src/index.ts +4 -4
- package/src/material-theme.ts +477 -477
- package/src/stories/Buttons.stories.tsx +15 -15
- package/src/stories/Buttons.tsx +52 -52
- package/src/stories/Checkbox.stories.tsx +15 -15
- package/src/stories/Checkbox.tsx +56 -56
- package/src/stories/ColorPalette.stories.tsx +15 -15
- package/src/stories/ColorPalette.tsx +85 -72
- package/src/stories/DocumentEditor.stories.tsx +287 -287
- package/src/stories/Dropdowns.stories.tsx +15 -15
- package/src/stories/Dropdowns.tsx +52 -52
- package/src/stories/InputPrompt.stories.tsx +15 -15
- package/src/stories/InputPrompt.tsx +63 -63
- package/src/stories/ModalHeader.stories.tsx +35 -35
- package/src/stories/PDFViewer.stories.tsx +39 -39
- package/src/stories/SearchModal.stories.tsx +132 -132
- package/src/stories/SearchModal.tsx +82 -82
- package/src/stories/Sidebar.stories.tsx +15 -15
- package/src/stories/Sidebar.tsx +108 -108
- package/src/stories/Tabs.stories.tsx +51 -51
- package/src/stories/Typography.stories.tsx +15 -15
- package/src/stories/Typography.tsx +110 -110
- package/src/style.css +2 -2
- package/src/styles/tabs.css +55 -55
- package/src/tailwind-theme.ts +243 -231
- package/src/types/documentEditor.ts +55 -55
- package/src/utils/checkboxModule.ts +241 -241
- package/src/utils/cn.ts +11 -11
|
@@ -1,320 +1,320 @@
|
|
|
1
|
-
import { Dialog, DialogBody } from "@material-tailwind/react";
|
|
2
|
-
import { useEffect, useRef, useState } from "react";
|
|
3
|
-
import { useHotkeys } from "react-hotkeys-hook";
|
|
4
|
-
import { useMediaQuery } from "@uidotdev/usehooks";
|
|
5
|
-
import { cn } from "../utils/cn";
|
|
6
|
-
|
|
7
|
-
export const formatDateReport = (date: string | Date) => {
|
|
8
|
-
const formattedDate = new Date(date);
|
|
9
|
-
return formattedDate.toLocaleDateString("en-US", {
|
|
10
|
-
month: "long",
|
|
11
|
-
day: "numeric",
|
|
12
|
-
year: "numeric",
|
|
13
|
-
});
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const highlightText = (text: string, searchTerm: string) => {
|
|
17
|
-
if (!searchTerm) return text;
|
|
18
|
-
|
|
19
|
-
const regex = new RegExp(`(\\b${searchTerm}\\b)`, "gi");
|
|
20
|
-
const parts = text.split(regex);
|
|
21
|
-
const firstMatchIndex = parts.findIndex((part) => regex.test(part));
|
|
22
|
-
|
|
23
|
-
if (firstMatchIndex === -1) return text;
|
|
24
|
-
|
|
25
|
-
if (firstMatchIndex === 0) {
|
|
26
|
-
return parts.map((part, i) =>
|
|
27
|
-
regex.test(part) ? (
|
|
28
|
-
<span key={i} className="text-gray-900 font-medium">
|
|
29
|
-
{part}
|
|
30
|
-
</span>
|
|
31
|
-
) : (
|
|
32
|
-
part
|
|
33
|
-
),
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const matchedPart = parts[firstMatchIndex];
|
|
38
|
-
const beforeMatch = parts.slice(0, firstMatchIndex).join("");
|
|
39
|
-
const afterMatch = parts.slice(firstMatchIndex + 1).join("");
|
|
40
|
-
|
|
41
|
-
return (
|
|
42
|
-
<>
|
|
43
|
-
<span key="match" className="text-gray-900 font-medium">
|
|
44
|
-
{beforeMatch && <>...</>}
|
|
45
|
-
{matchedPart}
|
|
46
|
-
</span>
|
|
47
|
-
{afterMatch && <span key="after"> {afterMatch}</span>}
|
|
48
|
-
</>
|
|
49
|
-
);
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
export interface SearchItem {
|
|
53
|
-
id: string;
|
|
54
|
-
title: string;
|
|
55
|
-
subtitle?: string;
|
|
56
|
-
icon?: React.ReactNode;
|
|
57
|
-
metadata?: Record<string, any>;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export interface SearchModalProps {
|
|
61
|
-
open: boolean;
|
|
62
|
-
onClose: () => void;
|
|
63
|
-
items?: SearchItem[];
|
|
64
|
-
loading?: boolean;
|
|
65
|
-
placeholder?: string;
|
|
66
|
-
onSearch?: (query: string) => void;
|
|
67
|
-
onSelect?: (item: SearchItem) => void;
|
|
68
|
-
filterOptions?: string[];
|
|
69
|
-
selectedFilter?: string;
|
|
70
|
-
onFilterChange?: (filter: string) => void;
|
|
71
|
-
recentlyViewedLabel?: string;
|
|
72
|
-
noResultsText?: string;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const SearchFilters = ({
|
|
76
|
-
filters,
|
|
77
|
-
selected,
|
|
78
|
-
onChange,
|
|
79
|
-
}: {
|
|
80
|
-
filters: string[];
|
|
81
|
-
selected: string;
|
|
82
|
-
onChange: (filter: string) => void;
|
|
83
|
-
}) => {
|
|
84
|
-
return (
|
|
85
|
-
<div className="flex gap-2 px-2">
|
|
86
|
-
{filters.map((filter) => (
|
|
87
|
-
<button
|
|
88
|
-
key={filter}
|
|
89
|
-
onClick={() => onChange(filter)}
|
|
90
|
-
className={cn(
|
|
91
|
-
"px-3 py-1 text-sm rounded-full transition-colors",
|
|
92
|
-
selected === filter
|
|
93
|
-
? "bg-brand-600 text-white"
|
|
94
|
-
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
|
95
|
-
)}
|
|
96
|
-
>
|
|
97
|
-
{filter}
|
|
98
|
-
</button>
|
|
99
|
-
))}
|
|
100
|
-
</div>
|
|
101
|
-
);
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
const SearchSkeleton = () => {
|
|
105
|
-
return (
|
|
106
|
-
<div className="p-4 flex flex-col gap-[6px]">
|
|
107
|
-
{[...Array(8)].map((_, i) => (
|
|
108
|
-
<div key={i} className="flex items-center gap-3 animate-pulse py-2">
|
|
109
|
-
<div className="size-3 bg-gray-100 rounded-full" />
|
|
110
|
-
<div className={cn(i % 2 === 0 ? "w-[250px]" : "w-[320px]","h-3 bg-gray-100 rounded-full")} />
|
|
111
|
-
</div>
|
|
112
|
-
))}
|
|
113
|
-
</div>
|
|
114
|
-
);
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const SearchItemComponent = ({
|
|
118
|
-
item,
|
|
119
|
-
isActive,
|
|
120
|
-
onClick,
|
|
121
|
-
searchTerm,
|
|
122
|
-
}: {
|
|
123
|
-
item: SearchItem;
|
|
124
|
-
isActive: boolean;
|
|
125
|
-
onClick: () => void;
|
|
126
|
-
searchTerm: string;
|
|
127
|
-
}) => {
|
|
128
|
-
return (
|
|
129
|
-
<div
|
|
130
|
-
onClick={onClick}
|
|
131
|
-
className={cn(
|
|
132
|
-
"flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors",
|
|
133
|
-
isActive ? "bg-gray-100" : "hover:bg-gray-50",
|
|
134
|
-
)}
|
|
135
|
-
>
|
|
136
|
-
{item.icon && <div className="shrink-0 text-gray-600">{item.icon}</div>}
|
|
137
|
-
<div className="flex-1 min-w-0">
|
|
138
|
-
<div className="text-sm font-medium text-gray-900 truncate">
|
|
139
|
-
{item.title}
|
|
140
|
-
</div>
|
|
141
|
-
{item.subtitle && (
|
|
142
|
-
<div className="text-sm text-gray-600 truncate">
|
|
143
|
-
{typeof item.subtitle === "string"
|
|
144
|
-
? highlightText(item.subtitle, searchTerm)
|
|
145
|
-
: item.subtitle}
|
|
146
|
-
</div>
|
|
147
|
-
)}
|
|
148
|
-
</div>
|
|
149
|
-
</div>
|
|
150
|
-
);
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
const SearchModal = ({
|
|
154
|
-
open,
|
|
155
|
-
onClose,
|
|
156
|
-
items = [],
|
|
157
|
-
loading = false,
|
|
158
|
-
placeholder = "Search...",
|
|
159
|
-
onSearch,
|
|
160
|
-
onSelect,
|
|
161
|
-
filterOptions = [],
|
|
162
|
-
selectedFilter = "All",
|
|
163
|
-
onFilterChange,
|
|
164
|
-
recentlyViewedLabel = "Recently viewed",
|
|
165
|
-
noResultsText = "No results found.",
|
|
166
|
-
}: SearchModalProps) => {
|
|
167
|
-
const [searchValue, setSearchValue] = useState("");
|
|
168
|
-
const [activeIndex, setActiveIndex] = useState(-1);
|
|
169
|
-
const isMobile = useMediaQuery("only screen and (max-width: 768px)");
|
|
170
|
-
const itemRefs = useRef<HTMLDivElement[]>([]);
|
|
171
|
-
|
|
172
|
-
useEffect(() => {
|
|
173
|
-
itemRefs.current = itemRefs.current.slice(0, items.length);
|
|
174
|
-
}, [items]);
|
|
175
|
-
|
|
176
|
-
useEffect(() => {
|
|
177
|
-
if (activeIndex > items.length) {
|
|
178
|
-
setActiveIndex(-1);
|
|
179
|
-
}
|
|
180
|
-
}, [items, activeIndex]);
|
|
181
|
-
|
|
182
|
-
useEffect(() => {
|
|
183
|
-
const currentItem = itemRefs.current[activeIndex];
|
|
184
|
-
currentItem?.scrollIntoView({
|
|
185
|
-
behavior: "smooth",
|
|
186
|
-
block: "nearest",
|
|
187
|
-
});
|
|
188
|
-
}, [activeIndex]);
|
|
189
|
-
|
|
190
|
-
useHotkeys(
|
|
191
|
-
"ArrowUp",
|
|
192
|
-
() => {
|
|
193
|
-
if (items.length === 0) return;
|
|
194
|
-
setActiveIndex((prev) => (prev <= 0 ? items.length - 1 : prev - 1));
|
|
195
|
-
},
|
|
196
|
-
{
|
|
197
|
-
preventDefault: true,
|
|
198
|
-
enableOnFormTags: true,
|
|
199
|
-
enabled: open,
|
|
200
|
-
},
|
|
201
|
-
);
|
|
202
|
-
|
|
203
|
-
useHotkeys(
|
|
204
|
-
"ArrowDown",
|
|
205
|
-
() => {
|
|
206
|
-
if (items.length === 0) return;
|
|
207
|
-
setActiveIndex((prev) => (prev >= items.length - 1 ? 0 : prev + 1));
|
|
208
|
-
},
|
|
209
|
-
{
|
|
210
|
-
preventDefault: true,
|
|
211
|
-
enableOnFormTags: true,
|
|
212
|
-
enabled: open,
|
|
213
|
-
},
|
|
214
|
-
);
|
|
215
|
-
|
|
216
|
-
useHotkeys(
|
|
217
|
-
"Enter",
|
|
218
|
-
() => {
|
|
219
|
-
if (activeIndex >= 0 && activeIndex < items.length) {
|
|
220
|
-
handleItemSelect(items[activeIndex]);
|
|
221
|
-
}
|
|
222
|
-
},
|
|
223
|
-
{
|
|
224
|
-
enableOnFormTags: true,
|
|
225
|
-
preventDefault: true,
|
|
226
|
-
enabled: open,
|
|
227
|
-
},
|
|
228
|
-
);
|
|
229
|
-
|
|
230
|
-
const handleSearchChange = (value: string) => {
|
|
231
|
-
setSearchValue(value);
|
|
232
|
-
onSearch?.(value);
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
const handleItemSelect = (item: SearchItem) => {
|
|
236
|
-
onSelect?.(item);
|
|
237
|
-
handleClose();
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
const handleClose = () => {
|
|
241
|
-
setSearchValue("");
|
|
242
|
-
setActiveIndex(-1);
|
|
243
|
-
onClose();
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
const showFilters = filterOptions.length > 0 && searchValue;
|
|
247
|
-
const showRecentlyViewed = !searchValue && items.length > 0;
|
|
248
|
-
|
|
249
|
-
return (
|
|
250
|
-
<Dialog
|
|
251
|
-
open={open}
|
|
252
|
-
size="lg"
|
|
253
|
-
handler={handleClose}
|
|
254
|
-
className={cn(
|
|
255
|
-
isMobile && "w-screen m-0",
|
|
256
|
-
"focus:outline-none 2xl:min-w-px",
|
|
257
|
-
)}
|
|
258
|
-
>
|
|
259
|
-
<DialogBody className="relative px-0 py-2">
|
|
260
|
-
<div>
|
|
261
|
-
<div className="border-b border-b-gray-100 px-6 pb-2">
|
|
262
|
-
<input
|
|
263
|
-
type="text"
|
|
264
|
-
placeholder={placeholder}
|
|
265
|
-
value={searchValue}
|
|
266
|
-
onChange={(e) => handleSearchChange(e.target.value)}
|
|
267
|
-
className="w-full bg-transparent p-2 px-0 text-sm font-medium text-gray-600 placeholder-gray-400 outline-none focus:outline-none"
|
|
268
|
-
/>
|
|
269
|
-
</div>
|
|
270
|
-
|
|
271
|
-
{loading ? (
|
|
272
|
-
<SearchSkeleton />
|
|
273
|
-
) : (
|
|
274
|
-
<div className="p-4 flex flex-col gap-2">
|
|
275
|
-
{showRecentlyViewed && (
|
|
276
|
-
<p className="text-sm font-normal text-gray-600 px-2">
|
|
277
|
-
{recentlyViewedLabel}
|
|
278
|
-
</p>
|
|
279
|
-
)}
|
|
280
|
-
|
|
281
|
-
{showFilters && (
|
|
282
|
-
<SearchFilters
|
|
283
|
-
filters={filterOptions}
|
|
284
|
-
selected={selectedFilter}
|
|
285
|
-
onChange={(filter) => onFilterChange?.(filter)}
|
|
286
|
-
/>
|
|
287
|
-
)}
|
|
288
|
-
|
|
289
|
-
<div className="overflow-y-auto max-h-[320px]" tabIndex={0}>
|
|
290
|
-
{items.map((item, index) => (
|
|
291
|
-
<div
|
|
292
|
-
key={item.id}
|
|
293
|
-
ref={(el) => {
|
|
294
|
-
if (el) itemRefs.current[index] = el;
|
|
295
|
-
}}
|
|
296
|
-
>
|
|
297
|
-
<SearchItemComponent
|
|
298
|
-
item={item}
|
|
299
|
-
isActive={activeIndex === index}
|
|
300
|
-
onClick={() => handleItemSelect(item)}
|
|
301
|
-
searchTerm={searchValue}
|
|
302
|
-
/>
|
|
303
|
-
</div>
|
|
304
|
-
))}
|
|
305
|
-
|
|
306
|
-
{!loading && items.length === 0 && (
|
|
307
|
-
<div className="text-sm text-gray-900 font-medium">
|
|
308
|
-
{noResultsText}
|
|
309
|
-
</div>
|
|
310
|
-
)}
|
|
311
|
-
</div>
|
|
312
|
-
</div>
|
|
313
|
-
)}
|
|
314
|
-
</div>
|
|
315
|
-
</DialogBody>
|
|
316
|
-
</Dialog>
|
|
317
|
-
);
|
|
318
|
-
};
|
|
319
|
-
|
|
320
|
-
export default SearchModal;
|
|
1
|
+
import { Dialog, DialogBody } from "@material-tailwind/react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useHotkeys } from "react-hotkeys-hook";
|
|
4
|
+
import { useMediaQuery } from "@uidotdev/usehooks";
|
|
5
|
+
import { cn } from "../utils/cn";
|
|
6
|
+
|
|
7
|
+
export const formatDateReport = (date: string | Date) => {
|
|
8
|
+
const formattedDate = new Date(date);
|
|
9
|
+
return formattedDate.toLocaleDateString("en-US", {
|
|
10
|
+
month: "long",
|
|
11
|
+
day: "numeric",
|
|
12
|
+
year: "numeric",
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const highlightText = (text: string, searchTerm: string) => {
|
|
17
|
+
if (!searchTerm) return text;
|
|
18
|
+
|
|
19
|
+
const regex = new RegExp(`(\\b${searchTerm}\\b)`, "gi");
|
|
20
|
+
const parts = text.split(regex);
|
|
21
|
+
const firstMatchIndex = parts.findIndex((part) => regex.test(part));
|
|
22
|
+
|
|
23
|
+
if (firstMatchIndex === -1) return text;
|
|
24
|
+
|
|
25
|
+
if (firstMatchIndex === 0) {
|
|
26
|
+
return parts.map((part, i) =>
|
|
27
|
+
regex.test(part) ? (
|
|
28
|
+
<span key={i} className="text-gray-900 font-medium">
|
|
29
|
+
{part}
|
|
30
|
+
</span>
|
|
31
|
+
) : (
|
|
32
|
+
part
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const matchedPart = parts[firstMatchIndex];
|
|
38
|
+
const beforeMatch = parts.slice(0, firstMatchIndex).join("");
|
|
39
|
+
const afterMatch = parts.slice(firstMatchIndex + 1).join("");
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
<span key="match" className="text-gray-900 font-medium">
|
|
44
|
+
{beforeMatch && <>...</>}
|
|
45
|
+
{matchedPart}
|
|
46
|
+
</span>
|
|
47
|
+
{afterMatch && <span key="after"> {afterMatch}</span>}
|
|
48
|
+
</>
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export interface SearchItem {
|
|
53
|
+
id: string;
|
|
54
|
+
title: string;
|
|
55
|
+
subtitle?: string;
|
|
56
|
+
icon?: React.ReactNode;
|
|
57
|
+
metadata?: Record<string, any>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SearchModalProps {
|
|
61
|
+
open: boolean;
|
|
62
|
+
onClose: () => void;
|
|
63
|
+
items?: SearchItem[];
|
|
64
|
+
loading?: boolean;
|
|
65
|
+
placeholder?: string;
|
|
66
|
+
onSearch?: (query: string) => void;
|
|
67
|
+
onSelect?: (item: SearchItem) => void;
|
|
68
|
+
filterOptions?: string[];
|
|
69
|
+
selectedFilter?: string;
|
|
70
|
+
onFilterChange?: (filter: string) => void;
|
|
71
|
+
recentlyViewedLabel?: string;
|
|
72
|
+
noResultsText?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SearchFilters = ({
|
|
76
|
+
filters,
|
|
77
|
+
selected,
|
|
78
|
+
onChange,
|
|
79
|
+
}: {
|
|
80
|
+
filters: string[];
|
|
81
|
+
selected: string;
|
|
82
|
+
onChange: (filter: string) => void;
|
|
83
|
+
}) => {
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex gap-2 px-2">
|
|
86
|
+
{filters.map((filter) => (
|
|
87
|
+
<button
|
|
88
|
+
key={filter}
|
|
89
|
+
onClick={() => onChange(filter)}
|
|
90
|
+
className={cn(
|
|
91
|
+
"px-3 py-1 text-sm rounded-full transition-colors",
|
|
92
|
+
selected === filter
|
|
93
|
+
? "bg-brand-600 text-white"
|
|
94
|
+
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
{filter}
|
|
98
|
+
</button>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const SearchSkeleton = () => {
|
|
105
|
+
return (
|
|
106
|
+
<div className="p-4 flex flex-col gap-[6px]">
|
|
107
|
+
{[...Array(8)].map((_, i) => (
|
|
108
|
+
<div key={i} className="flex items-center gap-3 animate-pulse py-2">
|
|
109
|
+
<div className="size-3 bg-gray-100 rounded-full" />
|
|
110
|
+
<div className={cn(i % 2 === 0 ? "w-[250px]" : "w-[320px]","h-3 bg-gray-100 rounded-full")} />
|
|
111
|
+
</div>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const SearchItemComponent = ({
|
|
118
|
+
item,
|
|
119
|
+
isActive,
|
|
120
|
+
onClick,
|
|
121
|
+
searchTerm,
|
|
122
|
+
}: {
|
|
123
|
+
item: SearchItem;
|
|
124
|
+
isActive: boolean;
|
|
125
|
+
onClick: () => void;
|
|
126
|
+
searchTerm: string;
|
|
127
|
+
}) => {
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
onClick={onClick}
|
|
131
|
+
className={cn(
|
|
132
|
+
"flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors",
|
|
133
|
+
isActive ? "bg-gray-100" : "hover:bg-gray-50",
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
{item.icon && <div className="shrink-0 text-gray-600">{item.icon}</div>}
|
|
137
|
+
<div className="flex-1 min-w-0">
|
|
138
|
+
<div className="text-sm font-medium text-gray-900 truncate">
|
|
139
|
+
{item.title}
|
|
140
|
+
</div>
|
|
141
|
+
{item.subtitle && (
|
|
142
|
+
<div className="text-sm text-gray-600 truncate">
|
|
143
|
+
{typeof item.subtitle === "string"
|
|
144
|
+
? highlightText(item.subtitle, searchTerm)
|
|
145
|
+
: item.subtitle}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const SearchModal = ({
|
|
154
|
+
open,
|
|
155
|
+
onClose,
|
|
156
|
+
items = [],
|
|
157
|
+
loading = false,
|
|
158
|
+
placeholder = "Search...",
|
|
159
|
+
onSearch,
|
|
160
|
+
onSelect,
|
|
161
|
+
filterOptions = [],
|
|
162
|
+
selectedFilter = "All",
|
|
163
|
+
onFilterChange,
|
|
164
|
+
recentlyViewedLabel = "Recently viewed",
|
|
165
|
+
noResultsText = "No results found.",
|
|
166
|
+
}: SearchModalProps) => {
|
|
167
|
+
const [searchValue, setSearchValue] = useState("");
|
|
168
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
169
|
+
const isMobile = useMediaQuery("only screen and (max-width: 768px)");
|
|
170
|
+
const itemRefs = useRef<HTMLDivElement[]>([]);
|
|
171
|
+
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
itemRefs.current = itemRefs.current.slice(0, items.length);
|
|
174
|
+
}, [items]);
|
|
175
|
+
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (activeIndex > items.length) {
|
|
178
|
+
setActiveIndex(-1);
|
|
179
|
+
}
|
|
180
|
+
}, [items, activeIndex]);
|
|
181
|
+
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
const currentItem = itemRefs.current[activeIndex];
|
|
184
|
+
currentItem?.scrollIntoView({
|
|
185
|
+
behavior: "smooth",
|
|
186
|
+
block: "nearest",
|
|
187
|
+
});
|
|
188
|
+
}, [activeIndex]);
|
|
189
|
+
|
|
190
|
+
useHotkeys(
|
|
191
|
+
"ArrowUp",
|
|
192
|
+
() => {
|
|
193
|
+
if (items.length === 0) return;
|
|
194
|
+
setActiveIndex((prev) => (prev <= 0 ? items.length - 1 : prev - 1));
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
preventDefault: true,
|
|
198
|
+
enableOnFormTags: true,
|
|
199
|
+
enabled: open,
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
useHotkeys(
|
|
204
|
+
"ArrowDown",
|
|
205
|
+
() => {
|
|
206
|
+
if (items.length === 0) return;
|
|
207
|
+
setActiveIndex((prev) => (prev >= items.length - 1 ? 0 : prev + 1));
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
preventDefault: true,
|
|
211
|
+
enableOnFormTags: true,
|
|
212
|
+
enabled: open,
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
useHotkeys(
|
|
217
|
+
"Enter",
|
|
218
|
+
() => {
|
|
219
|
+
if (activeIndex >= 0 && activeIndex < items.length) {
|
|
220
|
+
handleItemSelect(items[activeIndex]);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
enableOnFormTags: true,
|
|
225
|
+
preventDefault: true,
|
|
226
|
+
enabled: open,
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const handleSearchChange = (value: string) => {
|
|
231
|
+
setSearchValue(value);
|
|
232
|
+
onSearch?.(value);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const handleItemSelect = (item: SearchItem) => {
|
|
236
|
+
onSelect?.(item);
|
|
237
|
+
handleClose();
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const handleClose = () => {
|
|
241
|
+
setSearchValue("");
|
|
242
|
+
setActiveIndex(-1);
|
|
243
|
+
onClose();
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const showFilters = filterOptions.length > 0 && searchValue;
|
|
247
|
+
const showRecentlyViewed = !searchValue && items.length > 0;
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<Dialog
|
|
251
|
+
open={open}
|
|
252
|
+
size="lg"
|
|
253
|
+
handler={handleClose}
|
|
254
|
+
className={cn(
|
|
255
|
+
isMobile && "w-screen m-0",
|
|
256
|
+
"focus:outline-none 2xl:min-w-px",
|
|
257
|
+
)}
|
|
258
|
+
>
|
|
259
|
+
<DialogBody className="relative px-0 py-2">
|
|
260
|
+
<div>
|
|
261
|
+
<div className="border-b border-b-gray-100 px-6 pb-2">
|
|
262
|
+
<input
|
|
263
|
+
type="text"
|
|
264
|
+
placeholder={placeholder}
|
|
265
|
+
value={searchValue}
|
|
266
|
+
onChange={(e) => handleSearchChange(e.target.value)}
|
|
267
|
+
className="w-full bg-transparent p-2 px-0 text-sm font-medium text-gray-600 placeholder-gray-400 outline-none focus:outline-none"
|
|
268
|
+
/>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
{loading ? (
|
|
272
|
+
<SearchSkeleton />
|
|
273
|
+
) : (
|
|
274
|
+
<div className="p-4 flex flex-col gap-2">
|
|
275
|
+
{showRecentlyViewed && (
|
|
276
|
+
<p className="text-sm font-normal text-gray-600 px-2">
|
|
277
|
+
{recentlyViewedLabel}
|
|
278
|
+
</p>
|
|
279
|
+
)}
|
|
280
|
+
|
|
281
|
+
{showFilters && (
|
|
282
|
+
<SearchFilters
|
|
283
|
+
filters={filterOptions}
|
|
284
|
+
selected={selectedFilter}
|
|
285
|
+
onChange={(filter) => onFilterChange?.(filter)}
|
|
286
|
+
/>
|
|
287
|
+
)}
|
|
288
|
+
|
|
289
|
+
<div className="overflow-y-auto max-h-[320px]" tabIndex={0}>
|
|
290
|
+
{items.map((item, index) => (
|
|
291
|
+
<div
|
|
292
|
+
key={item.id}
|
|
293
|
+
ref={(el) => {
|
|
294
|
+
if (el) itemRefs.current[index] = el;
|
|
295
|
+
}}
|
|
296
|
+
>
|
|
297
|
+
<SearchItemComponent
|
|
298
|
+
item={item}
|
|
299
|
+
isActive={activeIndex === index}
|
|
300
|
+
onClick={() => handleItemSelect(item)}
|
|
301
|
+
searchTerm={searchValue}
|
|
302
|
+
/>
|
|
303
|
+
</div>
|
|
304
|
+
))}
|
|
305
|
+
|
|
306
|
+
{!loading && items.length === 0 && (
|
|
307
|
+
<div className="text-sm text-gray-900 font-medium">
|
|
308
|
+
{noResultsText}
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
</DialogBody>
|
|
316
|
+
</Dialog>
|
|
317
|
+
);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
export default SearchModal;
|
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import { useContext, type ReactNode } from "react";
|
|
2
|
-
import { collapsedContext } from "./Provider";
|
|
3
|
-
|
|
4
|
-
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
|
5
|
-
children?: ReactNode;
|
|
6
|
-
icon?: ReactNode;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export const Button = ({ icon, children, ...props }: ButtonProps) => {
|
|
10
|
-
const { isCollapsed, isHovered } = useContext(collapsedContext)!;
|
|
11
|
-
|
|
12
|
-
return (
|
|
13
|
-
<button
|
|
14
|
-
className="flex items-center gap-2 w-full hover:bg-gray-100 text-caption-1-em text-gray-600 hover:text-gray-900 h-[38px] rounded-lg px-2"
|
|
15
|
-
{...props}
|
|
16
|
-
>
|
|
17
|
-
{icon} {(!isCollapsed || isHovered) && children}
|
|
18
|
-
</button>
|
|
19
|
-
);
|
|
20
|
-
};
|
|
1
|
+
import { useContext, type ReactNode } from "react";
|
|
2
|
+
import { collapsedContext } from "./Provider";
|
|
3
|
+
|
|
4
|
+
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
|
5
|
+
children?: ReactNode;
|
|
6
|
+
icon?: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const Button = ({ icon, children, ...props }: ButtonProps) => {
|
|
10
|
+
const { isCollapsed, isHovered } = useContext(collapsedContext)!;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<button
|
|
14
|
+
className="flex items-center gap-2 w-full hover:bg-gray-100 text-caption-1-em text-gray-600 hover:text-gray-900 h-[38px] rounded-lg px-2"
|
|
15
|
+
{...props}
|
|
16
|
+
>
|
|
17
|
+
{icon} {(!isCollapsed || isHovered) && children}
|
|
18
|
+
</button>
|
|
19
|
+
);
|
|
20
|
+
};
|