feat: enhance AddCohart component with new sections and improved navigation; UI fixes

This commit is contained in:
amee
2026-07-11 10:45:00 +05:30
parent 56aeda3f1a
commit 80265bdf6b
11 changed files with 371 additions and 187 deletions
+106 -76
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { FileText, ChevronRight, ChevronLeft, Users, CircleDollarSign, Globe, CircleCheck } from 'lucide-react';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { FileText, ChevronRight, ChevronLeft, User, CircleCheck, Globe } from 'lucide-react';
import {
CustomModal,
CustomInput,
@@ -10,6 +10,7 @@ import {
CustomStatus,
CustomRadio,
CustomCheckBox,
CustomAccordionSection,
} from '../../../components/custom';
import CustomSuccessModal from '../../../components/custom/CustomSuccessModal';
import { createCohart, updateCohart } from '../CohartManageApi';
@@ -27,6 +28,7 @@ import type {
CohartResponse,
CreateCohartPayload,
CohortStatus,
FlightType,
HighValuePassenger,
} from '../CohartManageTypes';
@@ -60,6 +62,9 @@ const AIRPORTS = [
{ label: 'SIN (Singapore)', value: 'SIN' },
];
const SECTION_ORDER = ['passenger', 'customer', 'journey'] as const;
type Section = typeof SECTION_ORDER[number];
const STEPS = [
{ num: 1, label: 'INFORMATION', subtitle: 'Cohort Identity' },
{ num: 2, label: 'TARGETING', subtitle: 'Targeting Criteria' },
@@ -95,34 +100,6 @@ interface AddCohartProps {
editData?: CohartResponse;
}
// ─── Accordion Section ───────────────────────────────────────────────────────
interface SectionProps {
icon: React.ReactNode;
title: string;
isOpen: boolean;
hasData: boolean;
onToggle: () => void;
children: React.ReactNode;
}
function AccordionSection({ icon, title, isOpen, hasData, onToggle, children }: SectionProps) {
return (
<div className="border border-gray-200 rounded-[12px] bg-gray-50/50">
<button
type="button"
onClick={onToggle}
className="w-full flex items-center gap-2 px-4 py-3.5 text-left"
>
{icon}
<span className="text-[11px] font-bold text-gray-700 uppercase tracking-wider flex-1">{title}</span>
{!isOpen && hasData && <CircleCheck size={18} className="text-[#1B9869]" strokeWidth={2} />}
</button>
{isOpen && <div className="px-4 pb-4">{children}</div>}
</div>
);
}
// ─── Master Data Dropdown wrapper ─────────────────────────────────────────────
function toDropdownOptions(items: MasterDataItem[]) {
@@ -175,6 +152,7 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
// Load master data when modal opens
useEffect(() => {
if (!isOpen) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch master data on modal open
setMasterDataLoading(true);
Promise.all([
getCabinClasses(),
@@ -201,6 +179,7 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
// Pre-fill form when editing
useEffect(() => {
if (isOpen && editData) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync form fields from editData when modal opens in edit mode
setStepOne({
name: editData.name,
status: editData.status,
@@ -249,7 +228,7 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
loyaltyTierIds: stepTwo.loyaltyTierIds.length ? stepTwo.loyaltyTierIds : undefined,
revenueSegmentIds: stepTwo.revenueSegmentIds.length ? stepTwo.revenueSegmentIds : undefined,
highValuePassenger: stepTwo.highValuePassenger || undefined,
flightType: stepTwo.flightType as any || undefined,
flightType: (stepTwo.flightType as FlightType) || undefined,
regionIds: stepTwo.regionIds.length ? stepTwo.regionIds : undefined,
tripPurposeIds: stepTwo.tripPurposeIds.length ? stepTwo.tripPurposeIds : undefined,
scopeType: stepTwo.scopeType || undefined,
@@ -288,25 +267,71 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
stepTwo.originAirports.length > 0 ||
stepTwo.destinationAirports.length > 0;
// Auto-advance to the next section the moment the active one gains its
// first value. Reopening a prior section later never re-triggers this,
// since it only fires on the false -> true transition while that section
// is the one currently open.
const sectionHasData: Partial<Record<Section, boolean>> = useMemo(
() => ({ passenger: passengerHasData, customer: customerHasData }),
[passengerHasData, customerHasData]
);
const prevSectionHasData = useRef(sectionHasData);
useEffect(() => {
const current = openSection as Section;
const currentIndex = SECTION_ORDER.indexOf(current);
const justFilled = !prevSectionHasData.current[current] && sectionHasData[current];
if (justFilled && currentIndex >= 0 && currentIndex < SECTION_ORDER.length - 1) {
setOpenSection(SECTION_ORDER[currentIndex + 1]);
}
prevSectionHasData.current = sectionHasData;
}, [sectionHasData, openSection]);
const advanceSection = () => {
const currentIndex = SECTION_ORDER.indexOf(openSection as Section);
if (currentIndex >= 0 && currentIndex < SECTION_ORDER.length - 1) {
setOpenSection(SECTION_ORDER[currentIndex + 1]);
}
};
const isOnFinalSection = openSection === 'journey';
// ─── Stepper ───────────────────────────────────────────────────────────────
const stepper = (
<div className="px-6 py-5 border-b border-gray-100 flex items-center">
{STEPS.map((s, i) => (
<React.Fragment key={s.num}>
<div className="flex flex-col min-w-[140px]">
<p className={`text-[10px] font-bold tracking-wider mb-0.5 ${step >= s.num ? 'text-[#1B9869]' : 'text-gray-400'}`}>
{s.num} {s.label}
</p>
<p className={`text-base font-bold ${step >= s.num ? 'text-gray-900' : 'text-gray-400'}`}>
{s.subtitle}
</p>
</div>
{i === 0 && (
<div className={`flex-1 h-px mx-6 ${step > 1 ? 'bg-[#1B9869]' : 'bg-gray-200'}`} />
)}
</React.Fragment>
))}
<div className="flex items-center gap-[18px]">
{STEPS.map((s, i) => {
const reached = step >= s.num;
const isCurrent = step === s.num;
const eyebrowColor = reached ? 'text-[#059669]' : 'text-slate-400';
const titleColor = !reached
? 'text-slate-400'
: isCurrent && i > 0
? 'text-[#0F172A]'
: 'text-[#059669]';
return (
<React.Fragment key={s.num}>
<div className={`flex flex-col gap-1 min-w-[140px] ${i === 1 ? 'items-end text-right' : ''}`}>
<p className={`text-[10px] font-semibold leading-none tracking-[1px] uppercase ${eyebrowColor}`}>
{s.num} {s.label}
</p>
<p className={`text-[18px] font-semibold leading-none ${titleColor}`}>
{s.subtitle}
</p>
</div>
{i === 0 && (
<div
className="flex-1 h-[2px] rounded-full"
style={
step > 1
? { background: 'linear-gradient(90deg, #EBF6F3 0%, #A9DACB 71.31%)' }
: { background: 'linear-gradient(90deg, #EBF6F3 0%, #CBD5E1 71.31%)' }
}
/>
)}
</React.Fragment>
);
})}
</div>
);
@@ -325,12 +350,18 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
)}
<CustomButton
variant="primary"
rightIcon={step === 2 ? undefined : <ChevronRight size={18} />}
onClick={step === 2 ? handleSubmit : () => setStep(2)}
rightIcon={step === 2 && isOnFinalSection ? undefined : <ChevronRight size={18} />}
onClick={
step === 1
? () => setStep(2)
: isOnFinalSection
? handleSubmit
: advanceSection
}
disabled={(step === 1 && !canAdvance) || submitting}
loading={submitting}
>
{step === 2 ? (isEditMode ? 'Update' : 'Submit') : 'Next Step'}
{step === 1 ? 'Next Step' : isOnFinalSection ? (isEditMode ? 'Update' : 'Submit') : 'Next'}
</CustomButton>
</div>
);
@@ -347,12 +378,11 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
title={isEditMode ? 'Edit Cohort' : 'New Dynamic Cohorts'}
description="Configure targeting criteria for high-precision recovery."
icon={<FileText size={20} />}
headerExtra={stepper}
footer={footer}
allowBackdropClose={false}
contentClassName="!p-0"
>
{stepper}
{error && (
<div className="mx-6 mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
@@ -394,31 +424,31 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
{step === 2 && (
<div className="flex flex-col gap-4">
{/* Summary card */}
<div className="bg-[#EEF3F1] rounded-[12px] px-5 py-4 flex items-center gap-6">
<div className="bg-[#F0F7FF] border border-[#D8E8FF] rounded-[12px] px-5 py-4 flex items-center justify-between gap-6">
<div className="flex flex-col gap-0.5 shrink-0">
<span className="text-[10px] font-bold text-[#1B9869] uppercase tracking-wider">Cohort Name</span>
<span className="text-[14px] font-bold text-[#111827]">{stepOne.name}</span>
<span className="text-[10px] font-semibold leading-none tracking-[1px] text-[#059669] uppercase">Cohort Name</span>
<span className="text-[14px] font-semibold leading-none text-[#032D20]">{stepOne.name}</span>
</div>
{stepOne.description && (
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<span className="text-[10px] font-bold text-[#1B9869] uppercase tracking-wider">Description</span>
<span className="text-[13px] font-medium text-[#475569] truncate">{stepOne.description}</span>
<div className="flex flex-col gap-0.5 shrink-0 max-w-[280px]">
<span className="text-[10px] font-semibold leading-none tracking-[1px] text-[#059669] uppercase">Description</span>
<span className="text-[14px] font-semibold leading-none text-[#032D20] truncate">{stepOne.description}</span>
</div>
)}
<div className="ml-auto shrink-0">
<div className="shrink-0">
<CustomStatus status={stepOne.status} />
</div>
</div>
{/* Passenger Attributes */}
<AccordionSection
icon={<Users size={15} className="text-gray-700" strokeWidth={2} />}
<CustomAccordionSection
icon={<User size={15} className="text-gray-700" strokeWidth={2} />}
title="Passenger Attributes"
isOpen={openSection === 'passenger'}
hasData={passengerHasData}
onToggle={() => toggleSection('passenger')}
>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-5">
<CustomMultiSelect
label="Cabin Class"
options={toDropdownOptions(cabinClasses)}
@@ -444,18 +474,18 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
disabled={masterDataLoading}
/>
</div>
</AccordionSection>
</CustomAccordionSection>
{/* Customer Value */}
<AccordionSection
icon={<CircleDollarSign size={15} className="text-gray-700" strokeWidth={2} />}
<CustomAccordionSection
icon={<CircleCheck size={15} className="text-gray-700" strokeWidth={2} />}
title="Customer Value"
isOpen={openSection === 'customer'}
hasData={customerHasData}
onToggle={() => toggleSection('customer')}
>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-6">
<div className="grid grid-cols-2 gap-5">
<CustomMultiSelect
label="Loyalty Tier"
options={toDropdownOptions(membershipTiers)}
@@ -473,9 +503,9 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
disabled={masterDataLoading}
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3">
<span className="text-sm font-medium text-gray-900">High Value Passenger</span>
<div className="flex items-center gap-5">
<div className="flex items-center gap-7">
{HIGH_VALUE_OPTIONS.map(opt => (
<CustomRadio
key={opt.value}
@@ -489,20 +519,20 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
</div>
</div>
</div>
</AccordionSection>
</CustomAccordionSection>
{/* Journey Context */}
<AccordionSection
<CustomAccordionSection
icon={<Globe size={15} className="text-gray-700" strokeWidth={2} />}
title="Journey Context"
isOpen={openSection === 'journey'}
hasData={journeyHasData}
onToggle={() => toggleSection('journey')}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<span className="text-sm font-medium text-gray-900">Flight Type</span>
<div className="flex items-center gap-5">
<div className="flex items-center gap-7">
<CustomCheckBox
label="Domestic Only"
checked={stepTwo.flightType === 'Domestic Only'}
@@ -520,7 +550,7 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-5">
<CustomMultiSelect
label="Region"
options={toDropdownOptions(regions)}
@@ -611,7 +641,7 @@ export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddC
)}
</div>
</div>
</AccordionSection>
</CustomAccordionSection>
</div>
)}
</div>
@@ -0,0 +1,37 @@
import React from "react";
import { CircleCheck } from "lucide-react";
interface CustomAccordionSectionProps {
icon: React.ReactNode;
title: string;
isOpen: boolean;
hasData: boolean;
onToggle: () => void;
children: React.ReactNode;
}
const CustomAccordionSection: React.FC<CustomAccordionSectionProps> = ({
icon,
title,
isOpen,
hasData,
onToggle,
children,
}) => {
return (
<div className="border border-[#E3EDE5] rounded-[12px] bg-[#F8FAF8]">
<button
type="button"
onClick={onToggle}
className="w-full flex items-center gap-2 px-4 py-3.5 text-left"
>
{icon}
<span className="text-[11px] font-bold text-gray-700 uppercase tracking-wider flex-1">{title}</span>
{!isOpen && hasData && <CircleCheck size={18} className="text-[#1B9869]" strokeWidth={2} />}
</button>
{isOpen && <div className="px-4 pb-4 pt-3">{children}</div>}
</div>
);
};
export default CustomAccordionSection;
+1 -1
View File
@@ -30,7 +30,7 @@ const CustomCheckBox = forwardRef<HTMLInputElement, CustomCheckBoxProps>(
transition-all duration-200
flex items-center justify-center
border-gray-300 bg-white
peer-checked:bg-primary peer-checked:border-primary
peer-checked:border-transparent peer-checked:bg-gradient-to-b peer-checked:from-[#1B9869] peer-checked:to-[#14704E]
peer-checked:[&_svg]:opacity-100
peer-hover:border-primary
peer-focus:ring-2 peer-focus:ring-primary/20
+50 -35
View File
@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect } from "react";
import { ChevronDown, Check } from "lucide-react";
import DropdownPortal from "./DropdownPortal";
interface Option {
label: string;
@@ -40,6 +41,7 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const sizeClasses = {
sm: "py-1.5 text-sm h-[36px]",
@@ -49,9 +51,11 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
!dropdownRef.current.contains(target) &&
!(panelRef.current && panelRef.current.contains(target))
) {
setIsOpen(false);
}
@@ -122,41 +126,52 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
</div>
{/* Dropdown Menu */}
{isOpen && !disabled && (
<div className="absolute z-[9999] w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto flex flex-col">
{options.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 text-center">No options available</div>
) : (
options.map((option) => {
const isSelected = String(option.value) === String(value);
return (
<button
key={option.value}
type="button"
disabled={option.disabled}
onClick={(e) => {
e.stopPropagation();
handleSelect(option);
}}
className={`
w-full text-left px-4 py-2.5 text-[15px]
transition-colors duration-150 flex items-center gap-2
${isSelected
? "bg-[#F0FDF4] text-primary-dark font-medium"
: option.disabled
? "text-gray-400 cursor-not-allowed"
: "text-gray-700 hover:bg-gray-50"
}
`}
<DropdownPortal
anchorRef={dropdownRef}
isOpen={isOpen && !disabled}
ref={panelRef}
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto flex flex-col"
>
{options.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 text-center">No options available</div>
) : (
options.map((option) => {
const isSelected = String(option.value) === String(value);
return (
<button
key={option.value}
type="button"
disabled={option.disabled}
onClick={(e) => {
e.stopPropagation();
handleSelect(option);
}}
className={`
w-full text-left px-3 min-h-[42px] text-[14px] leading-none
transition-colors duration-150 flex items-center gap-2
${isSelected
? "bg-[#EEF9EF]"
: option.disabled
? "text-gray-400 cursor-not-allowed"
: "text-gray-700 hover:bg-gray-50"
}
`}
>
{isSelected && <Check size={16} className="text-primary shrink-0" strokeWidth={2.5} />}
<span
className={
isSelected
? "ml-1 font-medium bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent"
: "ml-6"
}
>
{isSelected && <Check size={16} className="text-primary shrink-0" strokeWidth={2.5} />}
<span className={isSelected ? "ml-1" : "ml-6"}>{option.label}</span>
</button>
);
})
)}
</div>
)}
{option.label}
</span>
</button>
);
})
)}
</DropdownPortal>
</div>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
+19 -13
View File
@@ -13,6 +13,7 @@ interface CustomModalProps {
title?: React.ReactNode;
description?: React.ReactNode;
icon?: React.ReactNode;
headerExtra?: React.ReactNode;
footer?: React.ReactNode;
primaryAction?: {
@@ -52,6 +53,7 @@ const CustomModal: React.FC<CustomModalProps> = ({
title,
description,
icon,
headerExtra,
footer,
primaryAction,
secondaryAction,
@@ -107,23 +109,27 @@ const CustomModal: React.FC<CustomModalProps> = ({
>
{/* Header */}
{(title || icon || showCloseButton) && (
<div className="relative border-b border-gray-100 px-6 py-5 flex-shrink-0 flex items-start gap-3">
<div className="relative border-b border-[#F1F5F9] bg-[#F8FAFC]/50 px-6 pt-5 pb-4 flex-shrink-0 flex items-start gap-3">
{icon && (
<div className="mt-1 flex-shrink-0 text-primary">
{icon}
</div>
)}
<div className="flex-1 pr-8">
{title && (
<h2 className="text-[17px] font-bold text-[#111827]">
{title}
</h2>
)}
{description && (
<p className="mt-0.5 text-[13px] font-medium text-slate-500">
{description}
</p>
)}
<div className="flex-1 pr-8 flex flex-col gap-6">
<div>
{title && (
<h2 className="text-[18px] font-semibold leading-[28px] tracking-normal text-[#0F172A]">
{title}
</h2>
)}
{description && (
<p className="mt-0.5 text-[13px] font-normal leading-none tracking-normal text-[#0F172A]">
{description}
</p>
)}
</div>
{headerExtra}
</div>
{showCloseButton && (
@@ -149,7 +155,7 @@ const CustomModal: React.FC<CustomModalProps> = ({
{/* Footer */}
{(footer || primaryAction || secondaryAction) && (
<div className={`flex items-center ${footer ? 'justify-end' : 'justify-between'} gap-3 border-t border-gray-100 bg-white px-6 py-4 flex-shrink-0`}>
<div className={`flex items-center ${footer ? 'justify-end' : 'justify-between'} gap-3 border-t border-[#F1F5F9] bg-[#F8FAFC]/50 px-6 py-4 flex-shrink-0`}>
{footer}
{!footer && (
<>
+45 -38
View File
@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect } from "react";
import { ChevronDown, Check } from "lucide-react";
import DropdownPortal from "./DropdownPortal";
interface Option {
label: string;
@@ -40,6 +41,7 @@ const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProp
) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const sizeClasses = {
sm: "py-1.5 text-sm min-h-[36px]",
@@ -49,9 +51,11 @@ const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProp
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
!dropdownRef.current.contains(target) &&
!(panelRef.current && panelRef.current.contains(target))
) {
setIsOpen(false);
}
@@ -131,43 +135,46 @@ const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProp
</div>
{/* Dropdown Menu */}
{isOpen && !disabled && (
<div className="absolute z-[9999] w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto flex flex-col">
{options.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 text-center">No options available</div>
) : (
options.map((option) => {
const isSelected = value.some(v => String(v) === String(option.value));
return (
<button
key={option.value}
type="button"
disabled={option.disabled}
onClick={(e) => {
e.stopPropagation();
handleSelect(option);
}}
className={`
w-full text-left px-4 py-2.5 text-[15px]
transition-colors duration-150 flex items-center gap-2
${isSelected
? "bg-[#F0FDF4] text-[#1B9869] font-medium"
: option.disabled
? "text-gray-400 cursor-not-allowed"
: "text-gray-700 hover:bg-gray-50"
}
`}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${isSelected ? 'bg-[#1B9869] border-[#1B9869]' : 'border-gray-300'}`}>
{isSelected && <Check size={12} className="text-white" strokeWidth={3} />}
</div>
<span className="ml-1">{option.label}</span>
</button>
);
})
)}
</div>
)}
<DropdownPortal
anchorRef={dropdownRef}
isOpen={isOpen && !disabled}
ref={panelRef}
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto flex flex-col"
>
{options.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 text-center">No options available</div>
) : (
options.map((option) => {
const isSelected = value.some(v => String(v) === String(option.value));
return (
<button
key={option.value}
type="button"
disabled={option.disabled}
onClick={(e) => {
e.stopPropagation();
handleSelect(option);
}}
className={`
w-full text-left px-4 py-2.5 text-[15px]
transition-colors duration-150 flex items-center gap-2
${isSelected
? "bg-[#F0FDF4] text-[#1B9869] font-medium"
: option.disabled
? "text-gray-400 cursor-not-allowed"
: "text-gray-700 hover:bg-gray-50"
}
`}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${isSelected ? 'bg-[#1B9869] border-[#1B9869]' : 'border-gray-300'}`}>
{isSelected && <Check size={12} className="text-white" strokeWidth={3} />}
</div>
<span className="ml-1">{option.label}</span>
</button>
);
})
)}
</DropdownPortal>
</div>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
+1 -1
View File
@@ -29,7 +29,7 @@ const CustomRadio = forwardRef<HTMLInputElement, CustomRadioProps>(
transition-all duration-200
flex items-center justify-center
border-gray-300 bg-white
peer-checked:border-primary peer-checked:bg-primary
peer-checked:border-transparent peer-checked:bg-gradient-to-b peer-checked:from-[#1B9869] peer-checked:to-[#14704E]
peer-checked:[&>div]:opacity-100
peer-hover:border-primary
peer-focus:ring-2 peer-focus:ring-primary/20
@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect } from "react";
import { ChevronDown, X, Check } from "lucide-react";
import DropdownPortal from "./DropdownPortal";
interface Option {
label: string;
@@ -43,6 +44,7 @@ const CustomSearchableDropdown = React.forwardRef<
const [highlightedIndex, setHighlightedIndex] = useState(0);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const sizeClasses = {
sm: "py-1 text-sm",
@@ -66,9 +68,11 @@ const CustomSearchableDropdown = React.forwardRef<
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
!dropdownRef.current.contains(target) &&
!(panelRef.current && panelRef.current.contains(target))
) {
setIsOpen(false);
// Revert to selected value label if closing without selection
@@ -216,9 +220,16 @@ const CustomSearchableDropdown = React.forwardRef<
</div>
{/* Dropdown Menu */}
{isOpen && !disabled && filteredOptions.length > 0 && (
<div className="absolute z-[9999] w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto flex flex-col">
{filteredOptions.map((option, index) => {
<DropdownPortal
anchorRef={dropdownRef}
isOpen={isOpen && !disabled}
ref={panelRef}
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto flex flex-col"
>
{filteredOptions.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 text-center">No results found</div>
) : (
filteredOptions.map((option, index) => {
const isSelected = String(option.value) === String(value);
return (
<button
@@ -227,10 +238,10 @@ const CustomSearchableDropdown = React.forwardRef<
onClick={() => handleSelect(option)}
onMouseEnter={() => setHighlightedIndex(index)}
className={`
w-full text-left px-4 py-2.5 text-[15px]
w-full text-left px-3 min-h-[42px] text-[14px] leading-none
transition-colors duration-150 flex items-center gap-2
${isSelected
? "bg-[#F0FDF4] text-primary-dark font-medium"
? "bg-[#EEF9EF]"
: highlightedIndex === index
? "bg-gray-50 text-gray-900"
: "text-gray-700 hover:bg-gray-50"
@@ -238,17 +249,20 @@ const CustomSearchableDropdown = React.forwardRef<
`}
>
{isSelected && <Check size={16} className="text-primary shrink-0" strokeWidth={2.5} />}
<span className={isSelected ? "ml-1" : "ml-6"}>{option.label}</span>
<span
className={
isSelected
? "ml-1 font-medium bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent"
: "ml-6"
}
>
{option.label}
</span>
</button>
);
})}
</div>
)}
{isOpen && !disabled && filteredOptions.length === 0 && (
<div className="absolute z-[9999] w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg p-4 text-center text-sm text-gray-500">
No results found
</div>
)}
})
)}
</DropdownPortal>
</div>
{props.error && <p className="text-xs text-red-500 mt-1">{props.error}</p>}
</div>
+7 -7
View File
@@ -34,12 +34,12 @@ const CustomStatus: React.FC<CustomStatusProps> = ({
const finalVariant = variant || getVariantFromStatus(status);
const styles = {
success: { bg: "bg-[#E4FAE7]", text: "text-[#258B33]", dot: "bg-[#258B33]" },
error: { bg: "bg-rose-50", text: "text-rose-600", dot: "bg-rose-600" },
warning: { bg: "bg-amber-50", text: "text-amber-600", dot: "bg-amber-600" },
info: { bg: "bg-sky-50", text: "text-sky-600", dot: "bg-sky-600" },
neutral: { bg: "bg-slate-100", text: "text-slate-600", dot: "bg-slate-600" },
brand: { bg: "bg-[#E4FAE7]", text: "text-[#258B33]", dot: "bg-[#258B33]" },
success: { bg: "bg-[#E4FAE7]", text: "text-[#1B9869]", dot: "bg-[#1B9869]", border: "border border-[#1B9869]" },
error: { bg: "bg-rose-50", text: "text-rose-600", dot: "bg-rose-600", border: "" },
warning: { bg: "bg-amber-50", text: "text-amber-600", dot: "bg-amber-600", border: "" },
info: { bg: "bg-sky-50", text: "text-sky-600", dot: "bg-sky-600", border: "" },
neutral: { bg: "bg-slate-100", text: "text-slate-600", dot: "bg-slate-600", border: "" },
brand: { bg: "bg-[#E4FAE7]", text: "text-[#1B9869]", dot: "bg-[#1B9869]", border: "border border-[#1B9869]" },
};
const currentStyle = styles[finalVariant];
@@ -56,7 +56,7 @@ const CustomStatus: React.FC<CustomStatusProps> = ({
className={`
inline-flex items-center justify-center gap-2 px-3 py-1.5 rounded-full
text-[11px] font-semibold leading-[16.5px] tracking-[0px] antialiased transition-all duration-300
${currentStyle.bg} ${currentStyle.text}
${currentStyle.bg} ${currentStyle.text} ${currentStyle.border}
${isClickable ? "hover:brightness-95 active:scale-95 cursor-pointer" : "cursor-default pointer-events-none"}
${className}
`}
+73
View File
@@ -0,0 +1,73 @@
import React, { useCallback, useLayoutEffect, useState } from "react";
import { createPortal } from "react-dom";
const GAP = 8;
const MAX_PANEL_HEIGHT = 240;
const MIN_PANEL_HEIGHT = 100;
interface DropdownPortalProps {
anchorRef: React.RefObject<HTMLElement | null>;
isOpen: boolean;
className?: string;
children: React.ReactNode;
}
// Portals the panel to document.body with fixed positioning so it isn't clipped by scrollable ancestors, and flips above the trigger when there's no room below.
const DropdownPortal = React.forwardRef<HTMLDivElement, DropdownPortalProps>(
({ anchorRef, isOpen, className = "", children }, forwardedRef) => {
const [style, setStyle] = useState<React.CSSProperties | null>(null);
const recompute = useCallback(() => {
const anchor = anchorRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const openUp = spaceBelow < MAX_PANEL_HEIGHT && spaceAbove > spaceBelow;
const clampHeight = (space: number) =>
Math.min(MAX_PANEL_HEIGHT, Math.max(space - GAP * 2, MIN_PANEL_HEIGHT));
setStyle({
position: "fixed",
left: rect.left,
width: rect.width,
...(openUp
? {
bottom: window.innerHeight - rect.top + GAP,
maxHeight: clampHeight(spaceAbove),
}
: {
top: rect.bottom + GAP,
maxHeight: clampHeight(spaceBelow),
}),
});
}, [anchorRef]);
useLayoutEffect(() => {
if (!isOpen) {
setStyle(null);
return;
}
recompute();
window.addEventListener("scroll", recompute, true);
window.addEventListener("resize", recompute);
return () => {
window.removeEventListener("scroll", recompute, true);
window.removeEventListener("resize", recompute);
};
}, [isOpen, recompute]);
if (!isOpen || !style) return null;
return createPortal(
<div ref={forwardedRef} style={style} className={`z-[10050] ${className}`}>
{children}
</div>,
document.body
);
}
);
DropdownPortal.displayName = "DropdownPortal";
export default DropdownPortal;
+3 -1
View File
@@ -22,6 +22,7 @@ import CustomStatus from "./CustomStatus";
import CustomAlertBanner from "./CustomAlertBanner";
import Skeleton from "./CustomSkeleton";
import CustomTimePicker from "./CustomTimePicker";
import CustomAccordionSection from "./CustomAccordionSection";
export {
CustomInput,
@@ -47,5 +48,6 @@ export {
CustomStatus,
CustomAlertBanner,
Skeleton,
CustomTimePicker
CustomTimePicker,
CustomAccordionSection
};