@redacto.io/consent-sdk-react 4.0.0 → 5.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/CHANGELOG.md +19 -1
- package/LICENSE +176 -0
- package/README.md +98 -1
- package/dist/index.js +231 -96
- package/dist/index.mjs +231 -96
- package/dist/privacy-center.d.mts +649 -0
- package/dist/privacy-center.d.ts +649 -0
- package/dist/privacy-center.js +20824 -0
- package/dist/privacy-center.mjs +20789 -0
- package/package.json +47 -3
- package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +6 -4
- package/src/RedactoNoticeConsent/api/index.ts +2 -0
- package/src/RedactoNoticeConsent/api/types.ts +2 -0
- package/src/RedactoNoticeConsent/components/DigiLockerCallback.tsx +128 -62
- package/src/RedactoNoticeConsent/language-codes.ts +97 -0
- package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +0 -3
- package/src/RedactoNoticeConsentInline/api/index.ts +0 -1
- package/src/RedactoPrivacyCenter/PrivacyCenterActivityPage.tsx +19 -0
- package/src/RedactoPrivacyCenter/PrivacyCenterCaseDetailsPage.tsx +154 -0
- package/src/RedactoPrivacyCenter/PrivacyCenterConsentManagerPage.tsx +17 -0
- package/src/RedactoPrivacyCenter/PrivacyCenterFormPage.tsx +79 -0
- package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +30 -0
- package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +173 -0
- package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +55 -0
- package/src/RedactoPrivacyCenter/api/actions.ts +476 -0
- package/src/RedactoPrivacyCenter/api/client.ts +524 -0
- package/src/RedactoPrivacyCenter/api/config.ts +33 -0
- package/src/RedactoPrivacyCenter/api/fetcher.ts +181 -0
- package/src/RedactoPrivacyCenter/api/upload.ts +93 -0
- package/src/RedactoPrivacyCenter/assets/character.svg +31 -0
- package/src/RedactoPrivacyCenter/assets/fileAttach.svg +9 -0
- package/src/RedactoPrivacyCenter/assets/greenTick.svg +4 -0
- package/src/RedactoPrivacyCenter/assets/mail.svg +3 -0
- package/src/RedactoPrivacyCenter/assets/poweredByRedacto.svg +9 -0
- package/src/RedactoPrivacyCenter/assets/sentImg.svg +9 -0
- package/src/RedactoPrivacyCenter/assets/uploadFileIcon.svg +9 -0
- package/src/RedactoPrivacyCenter/components/ActivitiesList.tsx +272 -0
- package/src/RedactoPrivacyCenter/components/CaseDetails/AttachmentCard.tsx +38 -0
- package/src/RedactoPrivacyCenter/components/CaseDetails/ChatBubble.tsx +31 -0
- package/src/RedactoPrivacyCenter/components/CaseDetails/DocumentRequestCard.tsx +73 -0
- package/src/RedactoPrivacyCenter/components/CaseDetails/MessageComposer.tsx +87 -0
- package/src/RedactoPrivacyCenter/components/CaseDetails/MessageItem.tsx +95 -0
- package/src/RedactoPrivacyCenter/components/CaseDetails/MessagesTab.tsx +147 -0
- package/src/RedactoPrivacyCenter/components/CaseDetails/RequestDetailsTab.tsx +171 -0
- package/src/RedactoPrivacyCenter/components/CaseDetails/StatusEvent.tsx +23 -0
- package/src/RedactoPrivacyCenter/components/CaseDetails/useFileUpload.ts +116 -0
- package/src/RedactoPrivacyCenter/components/CaseDetails/useMessagePolling.ts +36 -0
- package/src/RedactoPrivacyCenter/components/CaseDetails/utils.ts +56 -0
- package/src/RedactoPrivacyCenter/components/CaseDetailsModal.tsx +469 -0
- package/src/RedactoPrivacyCenter/components/CaseHistory.tsx +426 -0
- package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +732 -0
- package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +238 -0
- package/src/RedactoPrivacyCenter/components/ConsentManagerConstants.ts +7 -0
- package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +155 -0
- package/src/RedactoPrivacyCenter/components/DataRequest/CorrectionDataSection.tsx +189 -0
- package/src/RedactoPrivacyCenter/components/DataRequest/GrievanceDataSection.tsx +118 -0
- package/src/RedactoPrivacyCenter/components/DataRequest/NominationDataSection.tsx +66 -0
- package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +304 -0
- package/src/RedactoPrivacyCenter/components/Form.tsx +1596 -0
- package/src/RedactoPrivacyCenter/components/GrievanceTable.tsx +174 -0
- package/src/RedactoPrivacyCenter/components/GrievanceTablePagination.tsx +337 -0
- package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +330 -0
- package/src/RedactoPrivacyCenter/components/Pagination.tsx +140 -0
- package/src/RedactoPrivacyCenter/components/PrivacyCenterLayout.tsx +304 -0
- package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +232 -0
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideBarPC.tsx +475 -0
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideMenuPC.tsx +408 -0
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +42 -0
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/menuItemsPC.tsx +1 -0
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +40 -0
- package/src/RedactoPrivacyCenter/components/SideMenuBarPC/utils.ts +38 -0
- package/src/RedactoPrivacyCenter/components/ThemeTransitionOverlay.tsx +85 -0
- package/src/RedactoPrivacyCenter/components/activitiesColumns.tsx +141 -0
- package/src/RedactoPrivacyCenter/components/caseHistoryColumns.tsx +159 -0
- package/src/RedactoPrivacyCenter/components/constant.ts +16 -0
- package/src/RedactoPrivacyCenter/components/types.ts +76 -0
- package/src/RedactoPrivacyCenter/components/utils.tsx +10 -0
- package/src/RedactoPrivacyCenter/config/routes.ts +9 -0
- package/src/RedactoPrivacyCenter/config/table.ts +25 -0
- package/src/RedactoPrivacyCenter/context/AuthContext.tsx +137 -0
- package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +41 -0
- package/src/RedactoPrivacyCenter/context/LocaleContext.tsx +11 -0
- package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +96 -0
- package/src/RedactoPrivacyCenter/context/OrganizationContext.tsx +30 -0
- package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +60 -0
- package/src/RedactoPrivacyCenter/context/theme.tsx +127 -0
- package/src/RedactoPrivacyCenter/context/types.ts +14 -0
- package/src/RedactoPrivacyCenter/lib/api.ts +566 -0
- package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +63 -0
- package/src/RedactoPrivacyCenter/lib/constants.ts +161 -0
- package/src/RedactoPrivacyCenter/lib/i18n.ts +81 -0
- package/src/RedactoPrivacyCenter/lib/locales.ts +105 -0
- package/src/RedactoPrivacyCenter/lib/queries.ts +134 -0
- package/src/RedactoPrivacyCenter/lib/types.ts +531 -0
- package/src/RedactoPrivacyCenter/lib/useCurrentLanguage.ts +37 -0
- package/src/RedactoPrivacyCenter/lib/utils.ts +626 -0
- package/src/RedactoPrivacyCenter/locales/as/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/bho/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/bn/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/brx/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/doi/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/en/translation.json +255 -0
- package/src/RedactoPrivacyCenter/locales/gom/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/gu/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/hi/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/kn/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/ks/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/mai/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/ml/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/mr/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/ne/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/or/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/pa/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/sa/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/sat/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/sd/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/ta/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/te/translation.json +280 -0
- package/src/RedactoPrivacyCenter/locales/ur/translation.json +280 -0
- package/src/RedactoPrivacyCenter/styles/base.css +117 -0
- package/src/RedactoPrivacyCenter/styles/injectStyles.ts +1725 -0
- package/src/RedactoPrivacyCenter/styles/pcStyles.ts +813 -0
- package/src/RedactoPrivacyCenter/styles/responsive.ts +138 -0
- package/src/RedactoPrivacyCenter/styles/variables.css +133 -0
- package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +367 -0
- package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +476 -0
- package/src/RedactoPrivacyCenter/table/index.ts +2 -0
- package/src/RedactoPrivacyCenter/types/images.d.ts +29 -0
- package/src/RedactoPrivacyCenter/ui/Footer.tsx +75 -0
- package/src/RedactoPrivacyCenter/ui/FormBlockWrapper.tsx +54 -0
- package/src/RedactoPrivacyCenter/ui/PCAccordion.tsx +93 -0
- package/src/RedactoPrivacyCenter/ui/PCActionIcon.tsx +47 -0
- package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +69 -0
- package/src/RedactoPrivacyCenter/ui/PCBadge.tsx +48 -0
- package/src/RedactoPrivacyCenter/ui/PCButton.tsx +77 -0
- package/src/RedactoPrivacyCenter/ui/PCCheckbox.tsx +55 -0
- package/src/RedactoPrivacyCenter/ui/PCCollapse.tsx +107 -0
- package/src/RedactoPrivacyCenter/ui/PCEmpty.tsx +35 -0
- package/src/RedactoPrivacyCenter/ui/PCInput.tsx +132 -0
- package/src/RedactoPrivacyCenter/ui/PCLoader.tsx +31 -0
- package/src/RedactoPrivacyCenter/ui/PCModal.tsx +106 -0
- package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +173 -0
- package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +387 -0
- package/src/RedactoPrivacyCenter/ui/PCTable.tsx +149 -0
- package/src/RedactoPrivacyCenter/ui/PCText.tsx +41 -0
- package/src/RedactoPrivacyCenter/ui/PCTextarea.tsx +43 -0
- package/src/RedactoPrivacyCenter/ui/PCTitle.tsx +37 -0
- package/src/RedactoPrivacyCenter/ui/PCTooltip.tsx +119 -0
- package/src/RedactoPrivacyCenter/ui/SimpleBar.tsx +25 -0
- package/src/RedactoPrivacyCenter/ui/StatusBadge.tsx +53 -0
- package/src/RedactoPrivacyCenter/ui/ThemeToggle.tsx +72 -0
- package/src/RedactoPrivacyCenter/ui/index.ts +18 -0
- package/src/RedactoPrivacyCenter/utils/cn.ts +28 -0
- package/src/RedactoPrivacyCenter/utils/date.ts +48 -0
- package/src/RedactoPrivacyCenter/utils/status.ts +111 -0
- package/src/RedactoPrivacyCenter/utils/toast.ts +130 -0
- package/src/privacy-center.ts +108 -0
- package/tsconfig.json +2 -0
- package/tsup.config.ts +16 -1
- package/.turbo/turbo-build.log +0 -21
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type ReactNode, type CSSProperties, useEffect } from 'react';
|
|
2
|
+
import { pcStyles } from '../styles/pcStyles';
|
|
3
|
+
import { X } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
export interface PCModalProps {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
style?: CSSProperties;
|
|
11
|
+
size?: 'sm' | 'md' | 'lg' | 'xl';
|
|
12
|
+
showCloseButton?: boolean;
|
|
13
|
+
title?: ReactNode;
|
|
14
|
+
footer?: ReactNode;
|
|
15
|
+
closeAriaLabel?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function PCModal({
|
|
19
|
+
isOpen,
|
|
20
|
+
onClose,
|
|
21
|
+
children,
|
|
22
|
+
className = '',
|
|
23
|
+
style = {},
|
|
24
|
+
size = 'md',
|
|
25
|
+
showCloseButton = true,
|
|
26
|
+
title,
|
|
27
|
+
footer,
|
|
28
|
+
closeAriaLabel = 'Close modal',
|
|
29
|
+
}: PCModalProps) {
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (isOpen) {
|
|
32
|
+
document.body.style.overflow = 'hidden';
|
|
33
|
+
} else {
|
|
34
|
+
document.body.style.overflow = '';
|
|
35
|
+
}
|
|
36
|
+
return () => {
|
|
37
|
+
document.body.style.overflow = '';
|
|
38
|
+
};
|
|
39
|
+
}, [isOpen]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
43
|
+
if (e.key === 'Escape' && isOpen) {
|
|
44
|
+
onClose();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
document.addEventListener('keydown', handleEscape);
|
|
48
|
+
return () => document.removeEventListener('keydown', handleEscape);
|
|
49
|
+
}, [isOpen, onClose]);
|
|
50
|
+
|
|
51
|
+
if (!isOpen) return null;
|
|
52
|
+
|
|
53
|
+
const overlayStyle: CSSProperties = {
|
|
54
|
+
...pcStyles.modalOverlay,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const modalStyle: CSSProperties = {
|
|
58
|
+
...pcStyles.modal,
|
|
59
|
+
...(size === 'sm' && pcStyles.modalSm),
|
|
60
|
+
...(size === 'md' && pcStyles.modalMd),
|
|
61
|
+
...(size === 'lg' && pcStyles.modalLg),
|
|
62
|
+
...(size === 'xl' && pcStyles.modalXl),
|
|
63
|
+
...style,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="pc-modal-overlay" style={overlayStyle} onClick={onClose}>
|
|
68
|
+
<div
|
|
69
|
+
className={`pc-modal ${className}`}
|
|
70
|
+
style={modalStyle}
|
|
71
|
+
onClick={(e) => e.stopPropagation()}
|
|
72
|
+
>
|
|
73
|
+
{(title || showCloseButton) && (
|
|
74
|
+
<div style={pcStyles.modalHeader}>
|
|
75
|
+
{title && <div style={{ flex: 1 }}>{title}</div>}
|
|
76
|
+
{showCloseButton && (
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
className="pc-modal-close-button"
|
|
80
|
+
style={pcStyles.modalCloseButton}
|
|
81
|
+
onClick={onClose}
|
|
82
|
+
aria-label={closeAriaLabel}
|
|
83
|
+
>
|
|
84
|
+
<X size={20} />
|
|
85
|
+
</button>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
{title ? (
|
|
90
|
+
<div style={pcStyles.modalBody}>
|
|
91
|
+
{children}
|
|
92
|
+
</div>
|
|
93
|
+
) : (
|
|
94
|
+
<div style={{ padding: pcStyles.modalBody.padding }}>
|
|
95
|
+
{children}
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
{footer && (
|
|
99
|
+
<div style={pcStyles.modalFooter}>
|
|
100
|
+
{footer}
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { type ReactNode, type CSSProperties, useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { pcStyles } from '../styles/pcStyles';
|
|
3
|
+
|
|
4
|
+
export interface PCPopoverProps {
|
|
5
|
+
content: ReactNode;
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
placement?: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end';
|
|
8
|
+
shadow?: 'sm' | 'md' | 'lg';
|
|
9
|
+
className?: string;
|
|
10
|
+
style?: CSSProperties;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function PCPopover({
|
|
14
|
+
content,
|
|
15
|
+
children,
|
|
16
|
+
placement = 'bottom',
|
|
17
|
+
shadow = 'sm',
|
|
18
|
+
className = '',
|
|
19
|
+
style = {},
|
|
20
|
+
}: PCPopoverProps) {
|
|
21
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
22
|
+
const [position, setPosition] = useState({ top: 0, left: 0 });
|
|
23
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
24
|
+
const popoverRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
|
|
26
|
+
const updatePosition = () => {
|
|
27
|
+
if (!triggerRef.current || !popoverRef.current) return;
|
|
28
|
+
|
|
29
|
+
const triggerRect = triggerRef.current.getBoundingClientRect();
|
|
30
|
+
const popoverRect = popoverRef.current.getBoundingClientRect();
|
|
31
|
+
const gap = 8;
|
|
32
|
+
const viewportHeight = window.innerHeight;
|
|
33
|
+
const viewportWidth = window.innerWidth;
|
|
34
|
+
|
|
35
|
+
let top = 0;
|
|
36
|
+
let left = 0;
|
|
37
|
+
|
|
38
|
+
const [vertical, horizontal] = placement.split('-');
|
|
39
|
+
const isTop = vertical === 'top';
|
|
40
|
+
const isBottom = vertical === 'bottom';
|
|
41
|
+
const isLeft = horizontal === 'left';
|
|
42
|
+
const isRight = horizontal === 'right';
|
|
43
|
+
|
|
44
|
+
// Calculate vertical position with viewport boundary detection
|
|
45
|
+
if (isTop) {
|
|
46
|
+
top = triggerRect.top - popoverRect.height - gap;
|
|
47
|
+
// If popover would go above viewport, flip to bottom
|
|
48
|
+
if (top < 0) {
|
|
49
|
+
top = triggerRect.bottom + gap;
|
|
50
|
+
}
|
|
51
|
+
} else if (isBottom) {
|
|
52
|
+
top = triggerRect.bottom + gap;
|
|
53
|
+
// If popover would go below viewport, flip to top
|
|
54
|
+
if (top + popoverRect.height > viewportHeight) {
|
|
55
|
+
top = triggerRect.top - popoverRect.height - gap;
|
|
56
|
+
// If still doesn't fit, adjust to fit within viewport
|
|
57
|
+
if (top < 0) {
|
|
58
|
+
top = Math.max(8, viewportHeight - popoverRect.height - 8);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2;
|
|
63
|
+
// Ensure popover stays within viewport
|
|
64
|
+
if (top < 0) top = 8;
|
|
65
|
+
if (top + popoverRect.height > viewportHeight) {
|
|
66
|
+
top = viewportHeight - popoverRect.height - 8;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Calculate horizontal position with viewport boundary detection
|
|
71
|
+
if (isLeft) {
|
|
72
|
+
left = triggerRect.left - popoverRect.width - gap;
|
|
73
|
+
if (left < 0) left = triggerRect.left;
|
|
74
|
+
} else if (isRight) {
|
|
75
|
+
left = triggerRect.right - popoverRect.width;
|
|
76
|
+
if (left < 0) left = Math.max(8, triggerRect.left);
|
|
77
|
+
} else {
|
|
78
|
+
left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2;
|
|
79
|
+
// Ensure popover stays within viewport
|
|
80
|
+
if (left < 0) left = 8;
|
|
81
|
+
if (left + popoverRect.width > viewportWidth) {
|
|
82
|
+
left = viewportWidth - popoverRect.width - 8;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setPosition({ top, left });
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (isOpen) {
|
|
91
|
+
updatePosition();
|
|
92
|
+
window.addEventListener('scroll', updatePosition, true);
|
|
93
|
+
window.addEventListener('resize', updatePosition);
|
|
94
|
+
return () => {
|
|
95
|
+
window.removeEventListener('scroll', updatePosition, true);
|
|
96
|
+
window.removeEventListener('resize', updatePosition);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}, [isOpen, placement]);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
103
|
+
if (
|
|
104
|
+
triggerRef.current &&
|
|
105
|
+
!triggerRef.current.contains(event.target as Node) &&
|
|
106
|
+
popoverRef.current &&
|
|
107
|
+
!popoverRef.current.contains(event.target as Node)
|
|
108
|
+
) {
|
|
109
|
+
setIsOpen(false);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (isOpen) {
|
|
114
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
115
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
116
|
+
}
|
|
117
|
+
}, [isOpen]);
|
|
118
|
+
|
|
119
|
+
// Enhanced 3D shadow effect with multiple layers
|
|
120
|
+
const getShadow = () => {
|
|
121
|
+
if (shadow === 'sm') {
|
|
122
|
+
return '0 2px 8px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)';
|
|
123
|
+
} else if (shadow === 'md') {
|
|
124
|
+
return '0 4px 16px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.05)';
|
|
125
|
+
} else if (shadow === 'lg') {
|
|
126
|
+
return '0 8px 24px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05)';
|
|
127
|
+
} else {
|
|
128
|
+
return '0 12px 32px rgba(0, 0, 0, 0.18), 0 6px 16px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.05)';
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const popoverStyle: CSSProperties = {
|
|
133
|
+
position: 'fixed',
|
|
134
|
+
backgroundColor: pcStyles.modal.backgroundColor,
|
|
135
|
+
border: `1px solid ${pcStyles.selectDropdown.border}`,
|
|
136
|
+
borderRadius: pcStyles.selectDropdown.borderRadius,
|
|
137
|
+
boxShadow: getShadow(),
|
|
138
|
+
zIndex: 1060,
|
|
139
|
+
top: `${position.top}px`,
|
|
140
|
+
left: `${position.left}px`,
|
|
141
|
+
display: isOpen ? 'block' : 'none',
|
|
142
|
+
overflow: 'visible',
|
|
143
|
+
maxHeight: 'none',
|
|
144
|
+
transform: 'translateZ(0)', // Enable hardware acceleration for smoother rendering
|
|
145
|
+
backfaceVisibility: 'hidden', // Improve rendering performance
|
|
146
|
+
...style,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<>
|
|
151
|
+
<div
|
|
152
|
+
ref={triggerRef}
|
|
153
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
154
|
+
style={{ display: 'inline-block', cursor: 'pointer' }}
|
|
155
|
+
>
|
|
156
|
+
{children}
|
|
157
|
+
</div>
|
|
158
|
+
{isOpen && (
|
|
159
|
+
<div
|
|
160
|
+
ref={popoverRef}
|
|
161
|
+
className={`pc-popover ${className}`}
|
|
162
|
+
style={popoverStyle}
|
|
163
|
+
>
|
|
164
|
+
{content}
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
</>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Compound component pattern
|
|
172
|
+
PCPopover.Trigger = ({ children }: { children: ReactNode }) => children;
|
|
173
|
+
PCPopover.Content = ({ children }: { children: ReactNode }) => children;
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ReactNode,
|
|
3
|
+
type CSSProperties,
|
|
4
|
+
useState,
|
|
5
|
+
useRef,
|
|
6
|
+
useEffect,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { pcStyles } from '../styles/pcStyles';
|
|
10
|
+
import { ChevronDown, X } from 'lucide-react';
|
|
11
|
+
|
|
12
|
+
const getVar = (varName: string): string => `var(${varName})`;
|
|
13
|
+
const useIsomorphicLayoutEffect =
|
|
14
|
+
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
|
15
|
+
|
|
16
|
+
export interface SelectOption {
|
|
17
|
+
label: string;
|
|
18
|
+
value: string | number;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PCSelectProps {
|
|
23
|
+
className?: string;
|
|
24
|
+
style?: CSSProperties;
|
|
25
|
+
placeholder?: string;
|
|
26
|
+
options: readonly SelectOption[];
|
|
27
|
+
value: SelectOption | null;
|
|
28
|
+
onChange?: (option: SelectOption | null) => void;
|
|
29
|
+
onClear?: () => void;
|
|
30
|
+
clearable?: boolean;
|
|
31
|
+
searchable?: boolean;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
size?: 'sm' | 'md' | 'lg';
|
|
34
|
+
label?: ReactNode;
|
|
35
|
+
/**
|
|
36
|
+
* If true, the dropdown list width will match the trigger width.
|
|
37
|
+
* Useful in tight filter rows where you want trigger + dropdown aligned.
|
|
38
|
+
*/
|
|
39
|
+
dropdownMatchTriggerWidth?: boolean;
|
|
40
|
+
searchPlaceholder?: string;
|
|
41
|
+
noOptionsMessage?: string;
|
|
42
|
+
clearAriaLabel?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function PCSelect({
|
|
46
|
+
className = '',
|
|
47
|
+
style = {},
|
|
48
|
+
placeholder = 'Select...',
|
|
49
|
+
options,
|
|
50
|
+
value,
|
|
51
|
+
onChange,
|
|
52
|
+
onClear,
|
|
53
|
+
clearable = false,
|
|
54
|
+
searchable = false,
|
|
55
|
+
disabled = false,
|
|
56
|
+
size = 'md',
|
|
57
|
+
label,
|
|
58
|
+
dropdownMatchTriggerWidth = false,
|
|
59
|
+
searchPlaceholder = 'Search...',
|
|
60
|
+
noOptionsMessage = 'No options found',
|
|
61
|
+
clearAriaLabel = 'Clear selection',
|
|
62
|
+
}: PCSelectProps) {
|
|
63
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
64
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
65
|
+
const [dropdownLayout, setDropdownLayout] = useState<{
|
|
66
|
+
position: 'top' | 'bottom';
|
|
67
|
+
maxHeightPx: number;
|
|
68
|
+
widthPx: number;
|
|
69
|
+
leftPx: number;
|
|
70
|
+
topPx: number;
|
|
71
|
+
} | null>(null);
|
|
72
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
73
|
+
const selectRef = useRef<HTMLDivElement>(null);
|
|
74
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
75
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
76
|
+
|
|
77
|
+
const filteredOptions = searchable && searchQuery
|
|
78
|
+
? options.filter(opt =>
|
|
79
|
+
opt.label.toLowerCase().includes(searchQuery.toLowerCase())
|
|
80
|
+
)
|
|
81
|
+
: options;
|
|
82
|
+
|
|
83
|
+
const measureTextWidth = (text: string, font: string): number => {
|
|
84
|
+
// Browser-only measurement (this runs inside useEffect)
|
|
85
|
+
const canvas = document.createElement('canvas');
|
|
86
|
+
const ctx = canvas.getContext('2d');
|
|
87
|
+
if (!ctx) return text.length * 8; // fallback estimate
|
|
88
|
+
ctx.font = font;
|
|
89
|
+
return ctx.measureText(text).width;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Calculate dropdown position and width - default to bottom, but check if we should open above
|
|
93
|
+
useIsomorphicLayoutEffect(() => {
|
|
94
|
+
if (!isOpen) {
|
|
95
|
+
setDropdownLayout(null);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (triggerRef.current && wrapperRef.current) {
|
|
100
|
+
const triggerRect = triggerRef.current.getBoundingClientRect();
|
|
101
|
+
const wrapperRect = wrapperRef.current.getBoundingClientRect();
|
|
102
|
+
const viewportHeight = window.innerHeight;
|
|
103
|
+
const viewportWidth = window.innerWidth;
|
|
104
|
+
const spaceBelow = viewportHeight - triggerRect.bottom;
|
|
105
|
+
const spaceAbove = triggerRect.top;
|
|
106
|
+
const estimatedDropdownHeight = Math.min(300, filteredOptions.length * 40 + (searchable ? 60 : 0));
|
|
107
|
+
const triggerWidth = triggerRect.width;
|
|
108
|
+
|
|
109
|
+
// Measure widest option label (use full `options` list so width is stable even when searching)
|
|
110
|
+
const computed = window.getComputedStyle(triggerRef.current);
|
|
111
|
+
const font = `${computed.fontWeight} ${computed.fontSize} ${computed.fontFamily}`;
|
|
112
|
+
const paddingLeft = parseFloat(computed.paddingLeft || '0') || 0;
|
|
113
|
+
const paddingRight = parseFloat(computed.paddingRight || '0') || 0;
|
|
114
|
+
// Chevron + spacing (and optional clear button) lives on the right
|
|
115
|
+
const iconAreaPx = clearable ? 52 : 32;
|
|
116
|
+
|
|
117
|
+
const maxLabelWidth = options.reduce((max, opt) => {
|
|
118
|
+
const w = measureTextWidth(opt.label ?? '', font);
|
|
119
|
+
return Math.max(max, w);
|
|
120
|
+
}, 0);
|
|
121
|
+
|
|
122
|
+
const desiredWidth = Math.ceil(
|
|
123
|
+
maxLabelWidth + paddingLeft + paddingRight + iconAreaPx
|
|
124
|
+
);
|
|
125
|
+
const viewportGutter = 16;
|
|
126
|
+
const maxAllowedWidth = Math.max(0, viewportWidth - viewportGutter * 2);
|
|
127
|
+
const widthPx = dropdownMatchTriggerWidth
|
|
128
|
+
? Math.min(triggerWidth, maxAllowedWidth)
|
|
129
|
+
: Math.max(triggerWidth, Math.min(desiredWidth, maxAllowedWidth));
|
|
130
|
+
|
|
131
|
+
// Default to bottom, but open above if there's not enough space below
|
|
132
|
+
const nextPosition: 'top' | 'bottom' =
|
|
133
|
+
spaceBelow < estimatedDropdownHeight && spaceAbove > spaceBelow
|
|
134
|
+
? 'top'
|
|
135
|
+
: 'bottom';
|
|
136
|
+
|
|
137
|
+
const availableSpace =
|
|
138
|
+
nextPosition === 'top' ? spaceAbove : spaceBelow;
|
|
139
|
+
const calculatedMaxHeight = Math.min(300, Math.max(150, availableSpace - 16));
|
|
140
|
+
|
|
141
|
+
// Position dropdown to the trigger (inside wrapper) so it always opens under the button
|
|
142
|
+
const gap = 4;
|
|
143
|
+
const triggerLeftInWrapper = triggerRect.left - wrapperRect.left;
|
|
144
|
+
const triggerTopInWrapper = triggerRect.top - wrapperRect.top;
|
|
145
|
+
|
|
146
|
+
// Prefer aligning left with the trigger; if it would overflow viewport, shift left.
|
|
147
|
+
const wouldOverflowRight =
|
|
148
|
+
triggerRect.left + widthPx > viewportWidth - viewportGutter;
|
|
149
|
+
const preferredLeftPx = wouldOverflowRight
|
|
150
|
+
? triggerLeftInWrapper + triggerWidth - widthPx
|
|
151
|
+
: triggerLeftInWrapper;
|
|
152
|
+
const minLeftPx = viewportGutter - wrapperRect.left;
|
|
153
|
+
const maxLeftPx = viewportWidth - viewportGutter - wrapperRect.left - widthPx;
|
|
154
|
+
const leftPx = Math.min(Math.max(preferredLeftPx, minLeftPx), maxLeftPx);
|
|
155
|
+
|
|
156
|
+
// IMPORTANT:
|
|
157
|
+
// Don't use transform for top-positioning because the open/close animation
|
|
158
|
+
// uses transform keyframes, which would temporarily override our transform
|
|
159
|
+
// and cause a visible jump (down then up). Compute an absolute top instead.
|
|
160
|
+
const effectiveDropdownHeightPx = Math.min(
|
|
161
|
+
estimatedDropdownHeight,
|
|
162
|
+
calculatedMaxHeight
|
|
163
|
+
);
|
|
164
|
+
const topPx = nextPosition === 'top'
|
|
165
|
+
? triggerTopInWrapper - gap - effectiveDropdownHeightPx
|
|
166
|
+
: triggerTopInWrapper + triggerRect.height + gap;
|
|
167
|
+
|
|
168
|
+
// Single atomic update prevents intermediate “jump” states.
|
|
169
|
+
setDropdownLayout({
|
|
170
|
+
position: nextPosition,
|
|
171
|
+
maxHeightPx: calculatedMaxHeight,
|
|
172
|
+
widthPx,
|
|
173
|
+
leftPx,
|
|
174
|
+
topPx,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}, [
|
|
178
|
+
isOpen,
|
|
179
|
+
filteredOptions.length,
|
|
180
|
+
searchable,
|
|
181
|
+
options,
|
|
182
|
+
clearable,
|
|
183
|
+
dropdownMatchTriggerWidth,
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
188
|
+
if (
|
|
189
|
+
selectRef.current &&
|
|
190
|
+
!selectRef.current.contains(event.target as Node) &&
|
|
191
|
+
dropdownRef.current &&
|
|
192
|
+
!dropdownRef.current.contains(event.target as Node)
|
|
193
|
+
) {
|
|
194
|
+
setIsOpen(false);
|
|
195
|
+
setSearchQuery('');
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if (isOpen) {
|
|
200
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
201
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
202
|
+
}
|
|
203
|
+
}, [isOpen]);
|
|
204
|
+
|
|
205
|
+
const handleSelect = (option: SelectOption) => {
|
|
206
|
+
if (option.disabled) return;
|
|
207
|
+
onChange?.(option);
|
|
208
|
+
setIsOpen(false);
|
|
209
|
+
setSearchQuery('');
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const handleClear = (e: React.MouseEvent) => {
|
|
213
|
+
e.stopPropagation();
|
|
214
|
+
onChange?.(null);
|
|
215
|
+
onClear?.();
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const selectStyle: CSSProperties = {
|
|
219
|
+
...pcStyles.select,
|
|
220
|
+
...(size === 'sm' && pcStyles.inputSm),
|
|
221
|
+
...(size === 'lg' && pcStyles.inputLg),
|
|
222
|
+
...(disabled && pcStyles.inputDisabled),
|
|
223
|
+
position: 'relative',
|
|
224
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
225
|
+
display: 'flex',
|
|
226
|
+
alignItems: 'center',
|
|
227
|
+
justifyContent: 'space-between',
|
|
228
|
+
...style,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const dropdownStyle: CSSProperties = {
|
|
232
|
+
...pcStyles.selectDropdown,
|
|
233
|
+
// Override maxWidth from base styles to prevent content-based expansion
|
|
234
|
+
maxWidth: 'none',
|
|
235
|
+
...(dropdownLayout?.position === 'top' ? {
|
|
236
|
+
marginTop: 0,
|
|
237
|
+
marginBottom: 0,
|
|
238
|
+
animation: 'pc-slideUp 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
|
239
|
+
} : {
|
|
240
|
+
marginTop: 0,
|
|
241
|
+
marginBottom: 0,
|
|
242
|
+
animation: 'pc-slideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
|
243
|
+
}),
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const optionStyle: CSSProperties = {
|
|
247
|
+
...pcStyles.selectOption,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const wrapperStyle: CSSProperties = {
|
|
251
|
+
...pcStyles.inputWrapper,
|
|
252
|
+
position: 'relative',
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<div ref={wrapperRef} className={className} style={wrapperStyle}>
|
|
257
|
+
{label && <label style={pcStyles.inputLabel}>{label}</label>}
|
|
258
|
+
<div ref={selectRef} style={{ position: 'relative' }}>
|
|
259
|
+
<div
|
|
260
|
+
ref={triggerRef}
|
|
261
|
+
className="pc-select"
|
|
262
|
+
style={selectStyle}
|
|
263
|
+
onClick={() => {
|
|
264
|
+
if (disabled) return;
|
|
265
|
+
// Reset layout so open always measures fresh.
|
|
266
|
+
setDropdownLayout(null);
|
|
267
|
+
setIsOpen((prev) => !prev);
|
|
268
|
+
}}
|
|
269
|
+
>
|
|
270
|
+
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
271
|
+
{value ? value.label : <span style={{ color: pcStyles.textSecondary.color }}>{placeholder}</span>}
|
|
272
|
+
</span>
|
|
273
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
|
274
|
+
{clearable && value && (
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
onClick={handleClear}
|
|
278
|
+
style={{
|
|
279
|
+
background: 'none',
|
|
280
|
+
border: 'none',
|
|
281
|
+
cursor: 'pointer',
|
|
282
|
+
padding: '0.25rem',
|
|
283
|
+
display: 'flex',
|
|
284
|
+
alignItems: 'center',
|
|
285
|
+
color: pcStyles.textSecondary.color,
|
|
286
|
+
}}
|
|
287
|
+
aria-label={clearAriaLabel}
|
|
288
|
+
>
|
|
289
|
+
<X size={16} />
|
|
290
|
+
</button>
|
|
291
|
+
)}
|
|
292
|
+
<ChevronDown
|
|
293
|
+
size={16}
|
|
294
|
+
style={{
|
|
295
|
+
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
296
|
+
transition: 'transform 0.2s',
|
|
297
|
+
color: pcStyles.textSecondary.color,
|
|
298
|
+
}}
|
|
299
|
+
/>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
{isOpen && !disabled && dropdownLayout && (
|
|
303
|
+
<div
|
|
304
|
+
ref={dropdownRef}
|
|
305
|
+
className="pc-select-dropdown"
|
|
306
|
+
style={{
|
|
307
|
+
...dropdownStyle,
|
|
308
|
+
position: 'absolute',
|
|
309
|
+
left: `${dropdownLayout.leftPx}px`,
|
|
310
|
+
top: `${dropdownLayout.topPx}px`,
|
|
311
|
+
width: `${dropdownLayout.widthPx}px`,
|
|
312
|
+
minWidth: `${dropdownLayout.widthPx}px`,
|
|
313
|
+
maxWidth: `${dropdownLayout.widthPx}px`,
|
|
314
|
+
maxHeight: `${dropdownLayout.maxHeightPx}px`,
|
|
315
|
+
}}
|
|
316
|
+
>
|
|
317
|
+
{searchable && (
|
|
318
|
+
<div style={{
|
|
319
|
+
padding: '0.5rem',
|
|
320
|
+
borderBottom: `1px solid ${getVar('--pc-border-color')}`,
|
|
321
|
+
backgroundColor: getVar('--pc-bg-primary'),
|
|
322
|
+
}}>
|
|
323
|
+
<input
|
|
324
|
+
type="text"
|
|
325
|
+
className="pc-input"
|
|
326
|
+
style={{
|
|
327
|
+
...pcStyles.input,
|
|
328
|
+
...pcStyles.inputSm,
|
|
329
|
+
width: '100%',
|
|
330
|
+
}}
|
|
331
|
+
placeholder={searchPlaceholder}
|
|
332
|
+
value={searchQuery}
|
|
333
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
334
|
+
onClick={(e) => e.stopPropagation()}
|
|
335
|
+
/>
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
{filteredOptions.length === 0 ? (
|
|
339
|
+
<div style={{
|
|
340
|
+
...optionStyle,
|
|
341
|
+
color: getVar('--pc-text-secondary'),
|
|
342
|
+
textAlign: 'center',
|
|
343
|
+
backgroundColor: getVar('--pc-bg-primary'),
|
|
344
|
+
}}>
|
|
345
|
+
{noOptionsMessage}
|
|
346
|
+
</div>
|
|
347
|
+
) : (
|
|
348
|
+
filteredOptions.map((option) => (
|
|
349
|
+
<div
|
|
350
|
+
key={String(option.value)}
|
|
351
|
+
className="pc-select-option"
|
|
352
|
+
style={{
|
|
353
|
+
...optionStyle,
|
|
354
|
+
backgroundColor: getVar('--pc-bg-primary'),
|
|
355
|
+
color: getVar('--pc-text-primary'),
|
|
356
|
+
transition: 'background-color 0.2s ease, color 0.2s ease',
|
|
357
|
+
whiteSpace: 'nowrap',
|
|
358
|
+
overflow: 'hidden',
|
|
359
|
+
textOverflow: 'ellipsis',
|
|
360
|
+
maxWidth: '100%',
|
|
361
|
+
...(option.disabled && { opacity: 0.5, cursor: 'not-allowed' }),
|
|
362
|
+
...(value?.value === option.value && {
|
|
363
|
+
backgroundColor: getVar('--pc-bg-hover'),
|
|
364
|
+
}),
|
|
365
|
+
}}
|
|
366
|
+
onClick={() => handleSelect(option)}
|
|
367
|
+
onMouseEnter={(e) => {
|
|
368
|
+
if (!option.disabled) {
|
|
369
|
+
e.currentTarget.style.backgroundColor = getVar('--pc-bg-hover');
|
|
370
|
+
}
|
|
371
|
+
}}
|
|
372
|
+
onMouseLeave={(e) => {
|
|
373
|
+
e.currentTarget.style.backgroundColor = value?.value === option.value
|
|
374
|
+
? getVar('--pc-bg-hover')
|
|
375
|
+
: getVar('--pc-bg-primary');
|
|
376
|
+
}}
|
|
377
|
+
>
|
|
378
|
+
{option.label}
|
|
379
|
+
</div>
|
|
380
|
+
))
|
|
381
|
+
)}
|
|
382
|
+
</div>
|
|
383
|
+
)}
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
}
|