@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.
Files changed (161) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/LICENSE +176 -0
  3. package/README.md +98 -1
  4. package/dist/index.js +231 -96
  5. package/dist/index.mjs +231 -96
  6. package/dist/privacy-center.d.mts +649 -0
  7. package/dist/privacy-center.d.ts +649 -0
  8. package/dist/privacy-center.js +20824 -0
  9. package/dist/privacy-center.mjs +20789 -0
  10. package/package.json +47 -3
  11. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +6 -4
  12. package/src/RedactoNoticeConsent/api/index.ts +2 -0
  13. package/src/RedactoNoticeConsent/api/types.ts +2 -0
  14. package/src/RedactoNoticeConsent/components/DigiLockerCallback.tsx +128 -62
  15. package/src/RedactoNoticeConsent/language-codes.ts +97 -0
  16. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +0 -3
  17. package/src/RedactoNoticeConsentInline/api/index.ts +0 -1
  18. package/src/RedactoPrivacyCenter/PrivacyCenterActivityPage.tsx +19 -0
  19. package/src/RedactoPrivacyCenter/PrivacyCenterCaseDetailsPage.tsx +154 -0
  20. package/src/RedactoPrivacyCenter/PrivacyCenterConsentManagerPage.tsx +17 -0
  21. package/src/RedactoPrivacyCenter/PrivacyCenterFormPage.tsx +79 -0
  22. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +30 -0
  23. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +173 -0
  24. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +55 -0
  25. package/src/RedactoPrivacyCenter/api/actions.ts +476 -0
  26. package/src/RedactoPrivacyCenter/api/client.ts +524 -0
  27. package/src/RedactoPrivacyCenter/api/config.ts +33 -0
  28. package/src/RedactoPrivacyCenter/api/fetcher.ts +181 -0
  29. package/src/RedactoPrivacyCenter/api/upload.ts +93 -0
  30. package/src/RedactoPrivacyCenter/assets/character.svg +31 -0
  31. package/src/RedactoPrivacyCenter/assets/fileAttach.svg +9 -0
  32. package/src/RedactoPrivacyCenter/assets/greenTick.svg +4 -0
  33. package/src/RedactoPrivacyCenter/assets/mail.svg +3 -0
  34. package/src/RedactoPrivacyCenter/assets/poweredByRedacto.svg +9 -0
  35. package/src/RedactoPrivacyCenter/assets/sentImg.svg +9 -0
  36. package/src/RedactoPrivacyCenter/assets/uploadFileIcon.svg +9 -0
  37. package/src/RedactoPrivacyCenter/components/ActivitiesList.tsx +272 -0
  38. package/src/RedactoPrivacyCenter/components/CaseDetails/AttachmentCard.tsx +38 -0
  39. package/src/RedactoPrivacyCenter/components/CaseDetails/ChatBubble.tsx +31 -0
  40. package/src/RedactoPrivacyCenter/components/CaseDetails/DocumentRequestCard.tsx +73 -0
  41. package/src/RedactoPrivacyCenter/components/CaseDetails/MessageComposer.tsx +87 -0
  42. package/src/RedactoPrivacyCenter/components/CaseDetails/MessageItem.tsx +95 -0
  43. package/src/RedactoPrivacyCenter/components/CaseDetails/MessagesTab.tsx +147 -0
  44. package/src/RedactoPrivacyCenter/components/CaseDetails/RequestDetailsTab.tsx +171 -0
  45. package/src/RedactoPrivacyCenter/components/CaseDetails/StatusEvent.tsx +23 -0
  46. package/src/RedactoPrivacyCenter/components/CaseDetails/useFileUpload.ts +116 -0
  47. package/src/RedactoPrivacyCenter/components/CaseDetails/useMessagePolling.ts +36 -0
  48. package/src/RedactoPrivacyCenter/components/CaseDetails/utils.ts +56 -0
  49. package/src/RedactoPrivacyCenter/components/CaseDetailsModal.tsx +469 -0
  50. package/src/RedactoPrivacyCenter/components/CaseHistory.tsx +426 -0
  51. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +732 -0
  52. package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +238 -0
  53. package/src/RedactoPrivacyCenter/components/ConsentManagerConstants.ts +7 -0
  54. package/src/RedactoPrivacyCenter/components/ConsentManagerTableSkeleton.tsx +155 -0
  55. package/src/RedactoPrivacyCenter/components/DataRequest/CorrectionDataSection.tsx +189 -0
  56. package/src/RedactoPrivacyCenter/components/DataRequest/GrievanceDataSection.tsx +118 -0
  57. package/src/RedactoPrivacyCenter/components/DataRequest/NominationDataSection.tsx +66 -0
  58. package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +304 -0
  59. package/src/RedactoPrivacyCenter/components/Form.tsx +1596 -0
  60. package/src/RedactoPrivacyCenter/components/GrievanceTable.tsx +174 -0
  61. package/src/RedactoPrivacyCenter/components/GrievanceTablePagination.tsx +337 -0
  62. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +330 -0
  63. package/src/RedactoPrivacyCenter/components/Pagination.tsx +140 -0
  64. package/src/RedactoPrivacyCenter/components/PrivacyCenterLayout.tsx +304 -0
  65. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +232 -0
  66. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideBarPC.tsx +475 -0
  67. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideMenuPC.tsx +408 -0
  68. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/constants.tsx +42 -0
  69. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/menuItemsPC.tsx +1 -0
  70. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +40 -0
  71. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/utils.ts +38 -0
  72. package/src/RedactoPrivacyCenter/components/ThemeTransitionOverlay.tsx +85 -0
  73. package/src/RedactoPrivacyCenter/components/activitiesColumns.tsx +141 -0
  74. package/src/RedactoPrivacyCenter/components/caseHistoryColumns.tsx +159 -0
  75. package/src/RedactoPrivacyCenter/components/constant.ts +16 -0
  76. package/src/RedactoPrivacyCenter/components/types.ts +76 -0
  77. package/src/RedactoPrivacyCenter/components/utils.tsx +10 -0
  78. package/src/RedactoPrivacyCenter/config/routes.ts +9 -0
  79. package/src/RedactoPrivacyCenter/config/table.ts +25 -0
  80. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +137 -0
  81. package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +41 -0
  82. package/src/RedactoPrivacyCenter/context/LocaleContext.tsx +11 -0
  83. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +96 -0
  84. package/src/RedactoPrivacyCenter/context/OrganizationContext.tsx +30 -0
  85. package/src/RedactoPrivacyCenter/context/PageChromeContext.tsx +60 -0
  86. package/src/RedactoPrivacyCenter/context/theme.tsx +127 -0
  87. package/src/RedactoPrivacyCenter/context/types.ts +14 -0
  88. package/src/RedactoPrivacyCenter/lib/api.ts +566 -0
  89. package/src/RedactoPrivacyCenter/lib/consent-manager/utils.tsx +63 -0
  90. package/src/RedactoPrivacyCenter/lib/constants.ts +161 -0
  91. package/src/RedactoPrivacyCenter/lib/i18n.ts +81 -0
  92. package/src/RedactoPrivacyCenter/lib/locales.ts +105 -0
  93. package/src/RedactoPrivacyCenter/lib/queries.ts +134 -0
  94. package/src/RedactoPrivacyCenter/lib/types.ts +531 -0
  95. package/src/RedactoPrivacyCenter/lib/useCurrentLanguage.ts +37 -0
  96. package/src/RedactoPrivacyCenter/lib/utils.ts +626 -0
  97. package/src/RedactoPrivacyCenter/locales/as/translation.json +280 -0
  98. package/src/RedactoPrivacyCenter/locales/bho/translation.json +280 -0
  99. package/src/RedactoPrivacyCenter/locales/bn/translation.json +280 -0
  100. package/src/RedactoPrivacyCenter/locales/brx/translation.json +280 -0
  101. package/src/RedactoPrivacyCenter/locales/doi/translation.json +280 -0
  102. package/src/RedactoPrivacyCenter/locales/en/translation.json +255 -0
  103. package/src/RedactoPrivacyCenter/locales/gom/translation.json +280 -0
  104. package/src/RedactoPrivacyCenter/locales/gu/translation.json +280 -0
  105. package/src/RedactoPrivacyCenter/locales/hi/translation.json +280 -0
  106. package/src/RedactoPrivacyCenter/locales/kn/translation.json +280 -0
  107. package/src/RedactoPrivacyCenter/locales/ks/translation.json +280 -0
  108. package/src/RedactoPrivacyCenter/locales/mai/translation.json +280 -0
  109. package/src/RedactoPrivacyCenter/locales/ml/translation.json +280 -0
  110. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +280 -0
  111. package/src/RedactoPrivacyCenter/locales/mr/translation.json +280 -0
  112. package/src/RedactoPrivacyCenter/locales/ne/translation.json +280 -0
  113. package/src/RedactoPrivacyCenter/locales/or/translation.json +280 -0
  114. package/src/RedactoPrivacyCenter/locales/pa/translation.json +280 -0
  115. package/src/RedactoPrivacyCenter/locales/sa/translation.json +280 -0
  116. package/src/RedactoPrivacyCenter/locales/sat/translation.json +280 -0
  117. package/src/RedactoPrivacyCenter/locales/sd/translation.json +280 -0
  118. package/src/RedactoPrivacyCenter/locales/ta/translation.json +280 -0
  119. package/src/RedactoPrivacyCenter/locales/te/translation.json +280 -0
  120. package/src/RedactoPrivacyCenter/locales/ur/translation.json +280 -0
  121. package/src/RedactoPrivacyCenter/styles/base.css +117 -0
  122. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +1725 -0
  123. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +813 -0
  124. package/src/RedactoPrivacyCenter/styles/responsive.ts +138 -0
  125. package/src/RedactoPrivacyCenter/styles/variables.css +133 -0
  126. package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +367 -0
  127. package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +476 -0
  128. package/src/RedactoPrivacyCenter/table/index.ts +2 -0
  129. package/src/RedactoPrivacyCenter/types/images.d.ts +29 -0
  130. package/src/RedactoPrivacyCenter/ui/Footer.tsx +75 -0
  131. package/src/RedactoPrivacyCenter/ui/FormBlockWrapper.tsx +54 -0
  132. package/src/RedactoPrivacyCenter/ui/PCAccordion.tsx +93 -0
  133. package/src/RedactoPrivacyCenter/ui/PCActionIcon.tsx +47 -0
  134. package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +69 -0
  135. package/src/RedactoPrivacyCenter/ui/PCBadge.tsx +48 -0
  136. package/src/RedactoPrivacyCenter/ui/PCButton.tsx +77 -0
  137. package/src/RedactoPrivacyCenter/ui/PCCheckbox.tsx +55 -0
  138. package/src/RedactoPrivacyCenter/ui/PCCollapse.tsx +107 -0
  139. package/src/RedactoPrivacyCenter/ui/PCEmpty.tsx +35 -0
  140. package/src/RedactoPrivacyCenter/ui/PCInput.tsx +132 -0
  141. package/src/RedactoPrivacyCenter/ui/PCLoader.tsx +31 -0
  142. package/src/RedactoPrivacyCenter/ui/PCModal.tsx +106 -0
  143. package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +173 -0
  144. package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +387 -0
  145. package/src/RedactoPrivacyCenter/ui/PCTable.tsx +149 -0
  146. package/src/RedactoPrivacyCenter/ui/PCText.tsx +41 -0
  147. package/src/RedactoPrivacyCenter/ui/PCTextarea.tsx +43 -0
  148. package/src/RedactoPrivacyCenter/ui/PCTitle.tsx +37 -0
  149. package/src/RedactoPrivacyCenter/ui/PCTooltip.tsx +119 -0
  150. package/src/RedactoPrivacyCenter/ui/SimpleBar.tsx +25 -0
  151. package/src/RedactoPrivacyCenter/ui/StatusBadge.tsx +53 -0
  152. package/src/RedactoPrivacyCenter/ui/ThemeToggle.tsx +72 -0
  153. package/src/RedactoPrivacyCenter/ui/index.ts +18 -0
  154. package/src/RedactoPrivacyCenter/utils/cn.ts +28 -0
  155. package/src/RedactoPrivacyCenter/utils/date.ts +48 -0
  156. package/src/RedactoPrivacyCenter/utils/status.ts +111 -0
  157. package/src/RedactoPrivacyCenter/utils/toast.ts +130 -0
  158. package/src/privacy-center.ts +108 -0
  159. package/tsconfig.json +2 -0
  160. package/tsup.config.ts +16 -1
  161. 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
+ }