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