@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.
- package/README.md +46 -0
- package/package.json +42 -0
- package/src/base-components/Accordion.tsx +561 -0
- package/src/base-components/Badge.tsx +191 -0
- package/src/base-components/Button.tsx +331 -0
- package/src/base-components/ButtonGroup.tsx +149 -0
- package/src/base-components/Card.tsx +250 -0
- package/src/base-components/Checkbox.tsx +49 -0
- package/src/base-components/ChipInput.tsx +208 -0
- package/src/base-components/CommonButton.tsx +33 -0
- package/src/base-components/DataTable.tsx +82 -0
- package/src/base-components/Divider.tsx +82 -0
- package/src/base-components/Dropdown.tsx +85 -0
- package/src/base-components/EmptyState.tsx +18 -0
- package/src/base-components/FilterPopover.tsx +50 -0
- package/src/base-components/Input.tsx +60 -0
- package/src/base-components/Modal.tsx +107 -0
- package/src/base-components/OtpVerificationModal.tsx +251 -0
- package/src/base-components/Pagination.tsx +51 -0
- package/src/base-components/PhoneInput.tsx +142 -0
- package/src/base-components/PopConfirm.tsx +350 -0
- package/src/base-components/SearchPopover.tsx +70 -0
- package/src/base-components/SearchableSelect.tsx +734 -0
- package/src/base-components/Select.tsx +49 -0
- package/src/base-components/Table.tsx +78 -0
- package/src/base-components/Textarea.tsx +45 -0
- package/src/base-components/ThemeProvider.tsx +92 -0
- package/src/base-components/Toaster.tsx +198 -0
- package/src/base-components/index.ts +32 -0
- package/src/components/DashboardLayout.tsx +326 -0
- package/src/components/ListPage.tsx +140 -0
- package/src/components/QuickAccess.tsx +118 -0
- package/src/components/UserMenu.tsx +138 -0
- package/src/helpers/bem.ts +13 -0
- package/src/helpers/cn.ts +9 -0
- package/src/index.ts +16 -0
- package/src/theme.css +285 -0
- 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
|
+
}
|