@exotic-holidays/ui 0.1.0

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.
Files changed (38) hide show
  1. package/README.md +46 -0
  2. package/package.json +42 -0
  3. package/src/base-components/Accordion.tsx +561 -0
  4. package/src/base-components/Badge.tsx +191 -0
  5. package/src/base-components/Button.tsx +331 -0
  6. package/src/base-components/ButtonGroup.tsx +149 -0
  7. package/src/base-components/Card.tsx +250 -0
  8. package/src/base-components/Checkbox.tsx +49 -0
  9. package/src/base-components/ChipInput.tsx +208 -0
  10. package/src/base-components/CommonButton.tsx +33 -0
  11. package/src/base-components/DataTable.tsx +82 -0
  12. package/src/base-components/Divider.tsx +82 -0
  13. package/src/base-components/Dropdown.tsx +85 -0
  14. package/src/base-components/EmptyState.tsx +18 -0
  15. package/src/base-components/FilterPopover.tsx +50 -0
  16. package/src/base-components/Input.tsx +60 -0
  17. package/src/base-components/Modal.tsx +107 -0
  18. package/src/base-components/OtpVerificationModal.tsx +251 -0
  19. package/src/base-components/Pagination.tsx +51 -0
  20. package/src/base-components/PhoneInput.tsx +142 -0
  21. package/src/base-components/PopConfirm.tsx +350 -0
  22. package/src/base-components/SearchPopover.tsx +70 -0
  23. package/src/base-components/SearchableSelect.tsx +734 -0
  24. package/src/base-components/Select.tsx +49 -0
  25. package/src/base-components/Table.tsx +78 -0
  26. package/src/base-components/Textarea.tsx +45 -0
  27. package/src/base-components/ThemeProvider.tsx +92 -0
  28. package/src/base-components/Toaster.tsx +198 -0
  29. package/src/base-components/index.ts +32 -0
  30. package/src/components/DashboardLayout.tsx +326 -0
  31. package/src/components/ListPage.tsx +140 -0
  32. package/src/components/QuickAccess.tsx +118 -0
  33. package/src/components/UserMenu.tsx +138 -0
  34. package/src/helpers/bem.ts +13 -0
  35. package/src/helpers/cn.ts +9 -0
  36. package/src/index.ts +16 -0
  37. package/src/theme.css +285 -0
  38. package/tsconfig.json +11 -0
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { cn } from '../helpers/cn';
5
+
6
+ export interface PaginationProps {
7
+ currentPage: number;
8
+ totalItems: number;
9
+ itemsPerPage: number;
10
+ onPageChange: (page: number) => void;
11
+ className?: string;
12
+ }
13
+
14
+ export const Pagination = ({
15
+ currentPage,
16
+ totalItems,
17
+ itemsPerPage,
18
+ onPageChange,
19
+ className
20
+ }: PaginationProps) => {
21
+ const totalPages = Math.ceil(totalItems / itemsPerPage);
22
+
23
+ if (totalPages === 0) {
24
+ return null;
25
+ }
26
+
27
+ const startItem = totalItems === 0 ? 0 : (currentPage - 1) * itemsPerPage + 1;
28
+ const endItem = Math.min(startItem + itemsPerPage - 1, totalItems);
29
+
30
+ return (
31
+ <div className={cn("px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-end gap-2", className)}>
32
+ <span className="text-[12px] sm:text-[13px] font-semibold text-primary mr-1 sm:mr-2">
33
+ {totalItems > 0 ? `${startItem}–${endItem} of ${totalItems}` : '0 of 0'}
34
+ </span>
35
+ <button
36
+ className={`w-8 h-8 rounded-full border border-border-subtle flex items-center justify-center text-foreground-muted transition-colors ${currentPage <= 1 || totalPages === 0 ? 'cursor-not-allowed' : 'cursor-pointer bg-primary text-white hover:bg-primary/90'}`}
37
+ disabled={currentPage <= 1 || totalPages === 0}
38
+ onClick={() => onPageChange(currentPage - 1)}
39
+ >
40
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
41
+ </button>
42
+ <button
43
+ className={`w-8 h-8 rounded-full border border-border-subtle flex items-center justify-center text-foreground-muted transition-colors ${currentPage >= totalPages || totalPages === 0 ? 'cursor-not-allowed' : 'cursor-pointer bg-primary text-white hover:bg-primary/90'}`}
44
+ disabled={currentPage >= totalPages || totalPages === 0}
45
+ onClick={() => onPageChange(currentPage + 1)}
46
+ >
47
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6" /></svg>
48
+ </button>
49
+ </div>
50
+ );
51
+ };
@@ -0,0 +1,142 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import { usePhoneInput, defaultCountries, FlagImage, parseCountry } from 'react-international-phone';
5
+ import 'react-international-phone/style.css';
6
+ import { cn } from '../helpers/cn';
7
+ import { Dropdown, DropdownItem } from './Dropdown';
8
+ import { Search, ChevronDown } from 'lucide-react';
9
+
10
+ export interface PhoneInputProps {
11
+ value?: string;
12
+ onChange: (value: string) => void;
13
+ label?: string;
14
+ error?: string;
15
+ isRequired?: boolean;
16
+ defaultCountry?: string;
17
+ className?: string;
18
+ placeholder?: string;
19
+ /** Leave the field empty (showing the placeholder) instead of pre-filling the dial code on mount. */
20
+ disableDialCodePrefill?: boolean;
21
+ }
22
+
23
+ export const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
24
+ ({ value, onChange, label, error, isRequired, defaultCountry = 'lk', className, placeholder, disableDialCodePrefill = false, ...props }, ref) => {
25
+ const {
26
+ inputValue,
27
+ handlePhoneValueChange,
28
+ inputRef,
29
+ country,
30
+ setCountry
31
+ } = usePhoneInput({
32
+ defaultCountry,
33
+ value,
34
+ countries: defaultCountries,
35
+ disableDialCodePrefill,
36
+ onChange: (data) => {
37
+ const dialCode = `+${data.country.dialCode}`;
38
+ let newPhone = data.phone;
39
+
40
+ // If the user types a leading 0 after the dial code and then another digit,
41
+ // strip the leading 0 (e.g. +9407 -> +947)
42
+ if (newPhone.startsWith(`${dialCode}0`) && newPhone.length > dialCode.length + 1) {
43
+ newPhone = newPhone.replace(`${dialCode}0`, dialCode);
44
+ }
45
+
46
+ onChange(newPhone);
47
+ }
48
+ });
49
+
50
+ const [searchQuery, setSearchQuery] = useState('');
51
+
52
+ const filteredCountries = defaultCountries.filter(c => {
53
+ const parsed = parseCountry(c);
54
+ const searchStr = searchQuery.toLowerCase();
55
+ return parsed.name.toLowerCase().includes(searchStr) ||
56
+ parsed.dialCode.includes(searchStr);
57
+ });
58
+
59
+ return (
60
+ <div className={cn("flex flex-col gap-1.5 w-full", className)}>
61
+ {label && (
62
+ <label className="text-[12px] font-normal text-foreground-subtle tracking-[0.3px] capitalize">
63
+ {label} {isRequired && <span className="text-danger-alt ml-0.5">*</span>}
64
+ </label>
65
+ )}
66
+ <div className={cn(
67
+ "relative group flex items-center bg-surface-1 border-[1.5px] border-border-subtle rounded-[10px] pl-0 pr-2 focus-within:border-primary focus-within:bg-surface-1 transition-all h-[42px]",
68
+ error && "border-danger-alt focus-within:border-danger-alt"
69
+ )}>
70
+
71
+ <Dropdown
72
+ contentClassName="w-[280px] p-0 flex flex-col overflow-hidden"
73
+ trigger={
74
+ <div className="flex items-center gap-1.5 h-[40px] px-2.5 hover:bg-surface-hover rounded-l-[8px] border-r border-transparent transition-colors cursor-pointer">
75
+ <FlagImage iso2={country.iso2} size="24px" className="rounded-sm" />
76
+ <ChevronDown size={14} className="text-foreground-subtle" />
77
+ </div>
78
+ }
79
+ >
80
+ <div onClick={(e) => e.stopPropagation()} className="relative border-b border-border-subtle p-2 bg-white">
81
+ <Search size={14} className="absolute left-4 top-1/2 -translate-y-1/2 text-foreground-subtle" />
82
+ <input
83
+ type="text"
84
+ placeholder="Search country or code..."
85
+ value={searchQuery}
86
+ onChange={(e) => setSearchQuery(e.target.value)}
87
+ className="w-full bg-white border-none outline-none py-1.5 pl-8 pr-3 text-[13px] text-foreground-1 placeholder:text-foreground-disabled focus:ring-0 shadow-none"
88
+ autoFocus
89
+ />
90
+ </div>
91
+ <div className="flex-1 overflow-y-auto custom-scrollbar flex flex-col max-h-[160px] py-1 bg-white">
92
+ {filteredCountries.map(c => {
93
+ const parsed = parseCountry(c);
94
+ return (
95
+ <DropdownItem
96
+ key={parsed.iso2}
97
+ isActive={country.iso2 === parsed.iso2}
98
+ onClick={() => {
99
+ setCountry(parsed.iso2);
100
+ setSearchQuery('');
101
+ }}
102
+ className="py-2.5 px-4 rounded-none"
103
+ >
104
+ <FlagImage iso2={parsed.iso2} size="20px" className="shrink-0 rounded-sm" />
105
+ <span className="flex-1 truncate text-[13px]">{parsed.name}</span>
106
+ <span className="text-foreground-subtle text-[12px]">+{parsed.dialCode}</span>
107
+ </DropdownItem>
108
+ );
109
+ })}
110
+ {filteredCountries.length === 0 && (
111
+ <div className="text-[13px] text-foreground-muted text-center py-4">
112
+ No countries found.
113
+ </div>
114
+ )}
115
+ </div>
116
+ </Dropdown>
117
+
118
+ <input
119
+ ref={(node) => {
120
+ inputRef.current = node;
121
+ if (typeof ref === 'function') ref(node);
122
+ else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
123
+ }}
124
+ className="flex-1 bg-white border-none outline-none py-2.5 px-2 text-[13.5px] text-foreground-1 placeholder:text-foreground-disabled focus:ring-0 shadow-none min-w-0"
125
+ placeholder={placeholder}
126
+ value={inputValue}
127
+ onChange={handlePhoneValueChange}
128
+ type="tel"
129
+ {...props}
130
+ />
131
+ </div>
132
+
133
+ {error && <span className="text-[11px] text-danger-alt font-medium">{error}</span>}
134
+ </div>
135
+ );
136
+ }
137
+ );
138
+
139
+ PhoneInput.displayName = 'PhoneInput';
140
+
141
+ import { isValidPhoneNumber, validatePhoneNumberLength } from 'libphonenumber-js/min';
142
+ export { isValidPhoneNumber, validatePhoneNumberLength };
@@ -0,0 +1,350 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef, useCallback, useEffect, useLayoutEffect } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { motion, AnimatePresence } from 'framer-motion';
6
+ import { block, modifier } from '../helpers/bem';
7
+ import { cn } from '../helpers/cn';
8
+
9
+ export type PopConfirmPlacement = 'top' | 'bottom' | 'left' | 'right';
10
+
11
+ export interface PopConfirmProps {
12
+ /** Element that triggers the popover (e.g. Button) */
13
+ trigger: React.ReactElement<{ onClick?: (e: React.MouseEvent) => void }>;
14
+
15
+ /** Called when user confirms. Call callback() to close. setLoading controls confirm button loading state. */
16
+ onConfirm: (callback: () => void, setLoading: (loading: boolean) => void) => void;
17
+
18
+ /** Called when user cancels. Call callback() to close. */
19
+ onCancel: (callback: () => void) => void;
20
+
21
+ /** Text for the confirm button (default: 'Confirm') */
22
+ confirmText?: string;
23
+
24
+ /** Text for the cancel button (default: 'Cancel') */
25
+ cancelText?: string;
26
+
27
+ /** Title shown in the popover */
28
+ title?: string;
29
+
30
+ /** Body/description text shown in the popover */
31
+ body?: string;
32
+
33
+ /** Optional icon shown in the popover */
34
+ icon?: React.ReactNode;
35
+
36
+ /** Placement of the popover relative to the trigger */
37
+ placement?: PopConfirmPlacement;
38
+
39
+ /** BEM class name prefix (default 'aceui') */
40
+ prefix?: string;
41
+
42
+ /** Additional class name for the popover */
43
+ className?: string;
44
+
45
+ /** Additional class name for the trigger wrapper (default: 'inline-block') */
46
+ triggerClassName?: string;
47
+ }
48
+
49
+ const defaultConfirmText = 'Yes';
50
+ const defaultTitleText = 'Confirmation';
51
+ const defaultBodyText = 'Are you sure?';
52
+ const defaultCancelText = 'Cancel';
53
+ const defaultIcon = <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" fill="currentColor"></path><path d="M12 16V12M12 8H12.01M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path></svg>
54
+
55
+ export const PopConfirm = ({
56
+ trigger,
57
+ onConfirm,
58
+ onCancel,
59
+ confirmText = defaultConfirmText,
60
+ cancelText = defaultCancelText,
61
+ title = defaultTitleText,
62
+ body = defaultBodyText,
63
+ icon = defaultIcon,
64
+ placement = 'bottom',
65
+ prefix = 'aceui',
66
+ className,
67
+ triggerClassName,
68
+ }: PopConfirmProps) => {
69
+ const [isOpen, setIsOpen] = useState(false);
70
+ const [isLoading, setIsLoading] = useState(false);
71
+ const triggerRef = useRef<HTMLDivElement>(null);
72
+ const popoverRef = useRef<HTMLDivElement>(null);
73
+
74
+ const close = useCallback(() => {
75
+ setIsOpen(false);
76
+ setIsLoading(false);
77
+ }, []);
78
+
79
+ const handleConfirm = useCallback(() => {
80
+ onConfirm(close, setIsLoading);
81
+ }, [onConfirm, close]);
82
+
83
+ const handleCancel = useCallback(() => {
84
+ onCancel(close);
85
+ }, [onCancel, close]);
86
+
87
+ const handleTriggerClick = useCallback(
88
+ (e: React.MouseEvent) => {
89
+ e.preventDefault();
90
+ e.stopPropagation();
91
+ setIsOpen((prev) => !prev);
92
+ },
93
+ []
94
+ );
95
+
96
+ const triggerWithProps = React.cloneElement(trigger, {
97
+ onClick: (e: React.MouseEvent) => {
98
+ trigger.props.onClick?.(e);
99
+ handleTriggerClick(e);
100
+ },
101
+ });
102
+
103
+ useEffect(() => {
104
+ if (!isOpen) return;
105
+
106
+ const handleClickOutside = (e: MouseEvent) => {
107
+ const target = e.target as Node;
108
+ if (
109
+ popoverRef.current?.contains(target) ||
110
+ triggerRef.current?.contains(target)
111
+ ) {
112
+ return;
113
+ }
114
+ close();
115
+ };
116
+
117
+ const handleEscape = (e: KeyboardEvent) => {
118
+ if (e.key === 'Escape') close();
119
+ };
120
+
121
+ document.addEventListener('mousedown', handleClickOutside);
122
+ document.addEventListener('keydown', handleEscape);
123
+ return () => {
124
+ document.removeEventListener('mousedown', handleClickOutside);
125
+ document.removeEventListener('keydown', handleEscape);
126
+ };
127
+ }, [isOpen, close]);
128
+
129
+ const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
130
+ const [resolvedPlacement, setResolvedPlacement] = useState<PopConfirmPlacement>(placement);
131
+
132
+ const updatePosition = useCallback(() => {
133
+ const triggerEl = triggerRef.current;
134
+ if (!triggerEl) return;
135
+
136
+ const rect = triggerEl.getBoundingClientRect();
137
+ const gap = 8;
138
+ const popoverWidth = 280;
139
+ const popoverHeight = 160;
140
+ const viewportWidth = window.innerWidth;
141
+ const viewportHeight = window.innerHeight;
142
+
143
+ const calcPosition = (p: PopConfirmPlacement) => {
144
+ let top = 0;
145
+ let left = 0;
146
+ switch (p) {
147
+ case 'top':
148
+ top = rect.top - popoverHeight - gap;
149
+ left = rect.left + rect.width / 2 - popoverWidth / 2;
150
+ break;
151
+ case 'bottom':
152
+ top = rect.bottom + gap;
153
+ left = rect.left + rect.width / 2 - popoverWidth / 2;
154
+ break;
155
+ case 'left':
156
+ top = rect.top + rect.height / 2 - popoverHeight / 2;
157
+ left = rect.left - popoverWidth - gap;
158
+ break;
159
+ case 'right':
160
+ top = rect.top + rect.height / 2 - popoverHeight / 2;
161
+ left = rect.right + gap;
162
+ break;
163
+ }
164
+ return { top, left };
165
+ };
166
+
167
+ const fitsInViewport = (pos: { top: number; left: number }) => {
168
+ return (
169
+ pos.top >= 0 &&
170
+ pos.left >= 0 &&
171
+ pos.top + popoverHeight <= viewportHeight &&
172
+ pos.left + popoverWidth <= viewportWidth
173
+ );
174
+ };
175
+
176
+ // Try preferred placement first
177
+ const pos = calcPosition(placement);
178
+ if (fitsInViewport(pos)) {
179
+ setResolvedPlacement(placement);
180
+ // Clamp left to prevent horizontal overflow
181
+ pos.left = Math.max(8, Math.min(pos.left, viewportWidth - popoverWidth - 8));
182
+ setPosition(pos);
183
+ return;
184
+ }
185
+
186
+ // Try opposite placement
187
+ const opposite: Record<PopConfirmPlacement, PopConfirmPlacement> = {
188
+ top: 'bottom', bottom: 'top', left: 'right', right: 'left',
189
+ };
190
+ const fallbackOrder: PopConfirmPlacement[] = [
191
+ opposite[placement],
192
+ ...(['top', 'bottom', 'left', 'right'] as PopConfirmPlacement[]).filter(
193
+ (p) => p !== placement && p !== opposite[placement]
194
+ ),
195
+ ];
196
+
197
+ for (const fallback of fallbackOrder) {
198
+ const fallbackPos = calcPosition(fallback);
199
+ if (fitsInViewport(fallbackPos)) {
200
+ setResolvedPlacement(fallback);
201
+ fallbackPos.left = Math.max(8, Math.min(fallbackPos.left, viewportWidth - popoverWidth - 8));
202
+ setPosition(fallbackPos);
203
+ return;
204
+ }
205
+ }
206
+
207
+ // None fit perfectly — use opposite and clamp
208
+ const finalPlacement = opposite[placement];
209
+ const finalPos = calcPosition(finalPlacement);
210
+ finalPos.top = Math.max(8, Math.min(finalPos.top, viewportHeight - popoverHeight - 8));
211
+ finalPos.left = Math.max(8, Math.min(finalPos.left, viewportWidth - popoverWidth - 8));
212
+ setResolvedPlacement(finalPlacement);
213
+ setPosition(finalPos);
214
+ }, [placement]);
215
+
216
+ useLayoutEffect(() => {
217
+ if (!isOpen || typeof document === 'undefined') return;
218
+
219
+ updatePosition();
220
+
221
+ const triggerEl = triggerRef.current;
222
+ if (!triggerEl) return;
223
+
224
+ const resizeObserver = new ResizeObserver(updatePosition);
225
+ resizeObserver.observe(triggerEl);
226
+
227
+ return () => resizeObserver.disconnect();
228
+ }, [isOpen, placement, updatePosition]);
229
+
230
+ useEffect(() => {
231
+ if (!isOpen) setPosition(null);
232
+ }, [isOpen]);
233
+
234
+ const blockClass = block(prefix, 'pop-confirm');
235
+ const bemClasses = [
236
+ blockClass,
237
+ modifier(prefix, 'pop-confirm', resolvedPlacement),
238
+ ].filter(Boolean) as string[];
239
+
240
+ const popoverContent = (
241
+ <AnimatePresence>
242
+ {isOpen && position && (
243
+ <>
244
+ <motion.div
245
+ className="fixed inset-0 z-[9998] bg-transparent"
246
+ onClick={(e) => e.stopPropagation()}
247
+ initial={{ opacity: 0 }}
248
+ animate={{ opacity: 1 }}
249
+ exit={{ opacity: 0 }}
250
+ transition={{ duration: 0.15 }}
251
+ aria-hidden="true"
252
+ />
253
+ <motion.div
254
+ ref={popoverRef}
255
+ role="dialog"
256
+ aria-modal="true"
257
+ aria-labelledby={title ? `${blockClass}-title` : undefined}
258
+ aria-describedby={body ? `${blockClass}-body` : undefined}
259
+ onClick={(e) => e.stopPropagation()}
260
+ className={cn(
261
+ bemClasses,
262
+ 'fixed z-[9999] w-[280px] min-h-[80px] rounded-[12px] bg-surface-1 shadow-[0_4px_24px_rgba(0,0,0,0.12)] p-4 flex flex-col gap-3',
263
+ className
264
+ )}
265
+ style={{
266
+ top: position.top,
267
+ left: position.left,
268
+ }}
269
+ initial={{ opacity: 0, scale: 0.95 }}
270
+ animate={{ opacity: 1, scale: 1 }}
271
+ exit={{ opacity: 0, scale: 0.95 }}
272
+ transition={{ duration: 0.15 }}
273
+ >
274
+ {/* Arrow pointer */}
275
+ <div
276
+ className={cn(
277
+ 'absolute w-3 h-3 bg-surface-1 rotate-45 shadow-[0_4px_24px_rgba(0,0,0,0.12)]',
278
+ resolvedPlacement === 'bottom' && 'top-[-6px] left-1/2 -translate-x-1/2',
279
+ resolvedPlacement === 'top' && 'bottom-[-6px] left-1/2 -translate-x-1/2',
280
+ resolvedPlacement === 'left' && 'right-[-6px] top-1/2 -translate-y-1/2',
281
+ resolvedPlacement === 'right' && 'left-[-6px] top-1/2 -translate-y-1/2',
282
+ )}
283
+ />
284
+ {(title || body || icon) && (
285
+ <div className="flex gap-3">
286
+ {icon && (
287
+ <div className="flex-shrink-0 text-primary-500 [&>svg]:w-5 [&>svg]:h-5">
288
+ {icon}
289
+ </div>
290
+ )}
291
+ <div className="flex-1 min-w-0">
292
+ {title && (
293
+ <h3
294
+ id={`${blockClass}-title`}
295
+ className="text-sm font-semibold text-foreground-0"
296
+ >
297
+ {title}
298
+ </h3>
299
+ )}
300
+ {body && (
301
+ <p
302
+ id={`${blockClass}-body`}
303
+ className="text-sm text-muted-foreground mt-0.5"
304
+ >
305
+ {body}
306
+ </p>
307
+ )}
308
+ </div>
309
+ </div>
310
+ )}
311
+ <div className="flex justify-end gap-2 mt-1">
312
+ <button
313
+ type="button"
314
+ onClick={handleCancel}
315
+ disabled={isLoading}
316
+ className="px-3 py-1.5 text-sm font-medium rounded-[8px] bg-default-100 text-default-700 hover:bg-default-200 transition-colors disabled:opacity-50 cursor-pointer"
317
+ >
318
+ {cancelText}
319
+ </button>
320
+ <button
321
+ type="button"
322
+ onClick={handleConfirm}
323
+ disabled={isLoading}
324
+ className="px-3 py-1.5 text-sm font-medium rounded-[8px] bg-primary-500 text-primary-foreground hover:bg-primary-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer inline-flex items-center gap-2"
325
+ >
326
+ {isLoading && (
327
+ <span
328
+ className="inline-block w-3.5 h-3.5 border-2 border-current border-r-transparent rounded-full animate-spin"
329
+ aria-hidden="true"
330
+ />
331
+ )}
332
+ {confirmText}
333
+ </button>
334
+ </div>
335
+ </motion.div>
336
+ </>
337
+ )}
338
+ </AnimatePresence>
339
+ );
340
+
341
+ return (
342
+ <div ref={triggerRef} className={triggerClassName || 'inline-block'}>
343
+ {triggerWithProps}
344
+ {typeof document !== 'undefined' &&
345
+ createPortal(popoverContent, document.body)}
346
+ </div>
347
+ );
348
+ };
349
+
350
+ PopConfirm.displayName = 'PopConfirm';
@@ -0,0 +1,70 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Search, X } from 'lucide-react';
5
+ import { Dropdown } from './Dropdown';
6
+ import { Input } from './Input';
7
+ import { Button } from './Button';
8
+ import { cn } from '../helpers/cn';
9
+
10
+ export interface SearchPopoverProps {
11
+ value: string;
12
+ onChange: (value: string) => void;
13
+ placeholder?: string;
14
+ className?: string;
15
+ }
16
+
17
+ /**
18
+ * Compact header search: an icon button that opens a small input popover.
19
+ * Once a query is set, the trigger collapses to a pill showing the term with
20
+ * an inline clear. Mirrors the jewelry-ui desktop search affordance.
21
+ */
22
+ export function SearchPopover({ value, onChange, placeholder = 'Search…', className }: SearchPopoverProps) {
23
+ return (
24
+ <div className={cn('relative', className)}>
25
+ <Dropdown
26
+ align="right"
27
+ contentClassName="w-[280px] p-2"
28
+ trigger={
29
+ value ? (
30
+ <div className="flex items-center gap-1.5 bg-primary-50 border border-primary-200 pl-3 pr-1.5 py-1.5 rounded-full">
31
+ <Search size={14} className="text-primary-600 shrink-0" />
32
+ <span className="text-[12px] font-semibold text-primary-700 max-w-[140px] truncate">{value}</span>
33
+ <span
34
+ role="button"
35
+ tabIndex={0}
36
+ aria-label="Clear search"
37
+ title="Clear search"
38
+ onClick={(e) => {
39
+ e.stopPropagation();
40
+ onChange('');
41
+ }}
42
+ className="p-0.5 rounded-full text-primary-600 hover:bg-primary-100 transition-colors cursor-pointer"
43
+ >
44
+ <X size={13} />
45
+ </span>
46
+ </div>
47
+ ) : (
48
+ <Button
49
+ variant="flat"
50
+ color="default"
51
+ size="small"
52
+ ariaLabel="Search"
53
+ icon={<Search size={18} />}
54
+ />
55
+ )
56
+ }
57
+ >
58
+ <div onClick={(e) => e.stopPropagation()}>
59
+ <Input
60
+ autoFocus
61
+ placeholder={placeholder}
62
+ icon={<Search size={16} className="text-foreground-disabled" />}
63
+ value={value}
64
+ onChange={(e) => onChange(e.target.value)}
65
+ />
66
+ </div>
67
+ </Dropdown>
68
+ </div>
69
+ );
70
+ }