From 97b1cddf85eef898ce69b52e7e3277fca2edede7 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Sun, 21 Jun 2026 15:30:08 +0330 Subject: [PATCH] fix combo box in glass --- .../[id]/components/ShippingSection.tsx | 1 - src/components/combobox/Combobox.tsx | 184 ++++++++++++------ src/components/input/InputField.tsx | 70 +++---- .../auth/components/StepEnterNumber.tsx | 183 ++++++++--------- 4 files changed, 234 insertions(+), 204 deletions(-) diff --git a/src/app/[name]/(Dialogs)/order/checkout/[id]/components/ShippingSection.tsx b/src/app/[name]/(Dialogs)/order/checkout/[id]/components/ShippingSection.tsx index 1f0f080..8544f65 100644 --- a/src/app/[name]/(Dialogs)/order/checkout/[id]/components/ShippingSection.tsx +++ b/src/app/[name]/(Dialogs)/order/checkout/[id]/components/ShippingSection.tsx @@ -81,4 +81,3 @@ export const ShippingSection = ({ deliveryType, onDeliveryTypeChange }: Shipping ); }; - diff --git a/src/components/combobox/Combobox.tsx b/src/components/combobox/Combobox.tsx index b1788cc..e306499 100644 --- a/src/components/combobox/Combobox.tsx +++ b/src/components/combobox/Combobox.tsx @@ -4,7 +4,8 @@ import clsx from "clsx"; import { motion, Variants } from "framer-motion"; import { Icon, SearchNormal } from "iconsax-react"; import { ChevronDown } from "lucide-react"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; export interface ComboboxOption { id: string; @@ -28,6 +29,12 @@ type Props = { ) => void; } & React.HTMLAttributes; +type DropdownPosition = { + top: number; + left: number; + width: number; +}; + function Combobox({ title, options, @@ -37,17 +44,53 @@ function Combobox({ selectedId, searchable = true, onSelectionChange, + className, ...props }: Props) { const [expand, setExpand] = useState(expanded); const [searchValue, setSearchValue] = useState(""); + const [dropdownPosition, setDropdownPosition] = useState(null); const boxRef = useRef(null); + const triggerRef = useRef(null); + const dropdownRef = useRef(null); + + const updateDropdownPosition = useCallback(() => { + if (!triggerRef.current) return; + + const rect = triggerRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + }); + }, []); + + useEffect(() => { + if (!expand) { + setDropdownPosition(null); + return; + } + + updateDropdownPosition(); + window.addEventListener("scroll", updateDropdownPosition, true); + window.addEventListener("resize", updateDropdownPosition); + + return () => { + window.removeEventListener("scroll", updateDropdownPosition, true); + window.removeEventListener("resize", updateDropdownPosition); + }; + }, [expand, updateDropdownPosition]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (boxRef.current && !boxRef.current.contains(event.target as Node)) { - setExpand(false); + const target = event.target as Node; + if ( + boxRef.current?.contains(target) || + dropdownRef.current?.contains(target) + ) { + return; } + setExpand(false); }; if (expand) { @@ -72,12 +115,14 @@ function Combobox({ e: React.MouseEvent, index: number, ) => { - e.stopPropagation(); // prevent toggle + e.stopPropagation(); onSelectionChange(e, index); setExpand(false); }; const selectedOption = options.find((x) => x.id === selectedId); + const filteredOptions = options.filter((v) => v.title?.includes(searchValue)); + const activeIcon = selectedOption?.icon && React.createElement(selectedOption.icon, { @@ -93,7 +138,7 @@ function Combobox({ collapse: { opacity: 0, scale: 0.95, - pointerEvents: "none", // disable clicks + pointerEvents: "none", transition: { duration: 0.1, ease: "easeInOut", @@ -102,7 +147,7 @@ function Combobox({ expand: { opacity: 1, scale: 1, - pointerEvents: "auto", // re-enable clicks + pointerEvents: "auto", transition: { duration: 0.1, ease: "easeInOut", @@ -110,13 +155,84 @@ function Combobox({ }, }; + const dropdown = + expand && + dropdownPosition && + typeof document !== "undefined" + ? createPortal( + e.stopPropagation()} + initial="collapse" + animate="expand" + variants={variants} + > + {searchable && ( +
+ + +
+ )} +
+ {filteredOptions.map((v) => { + const originalIndex = options.findIndex((o) => o.id === v.id); + + return ( +
setSelection(e, originalIndex)} + key={v.id} + data-selected={v.id === selectedId} + className="flex gap-3 items-center justify-end px-2 py-2 cursor-pointer hover:bg-current/10 rounded-md last:rounded-b-xl" + tabIndex={0} + role="option" + aria-selected={v.id === selectedId} + > + {v?.imagePath ? ( + + ) : ( + v?.icon && + React.createElement(v.icon, { + size: 16, + color: "currentColor", + className: + "inline-block shrink-0 text-primary dark:text-foreground", + }) + )} + + {v.title} + +
+ ); + })} +
+
, + document.body, + ) + : null; + return (
- e.stopPropagation()} - initial="collapse" - animate={expand ? "expand" : "collapse"} - variants={variants} - > - {searchable && ( -
- - -
- )} -
- {options - .filter((v) => v.title?.includes(searchValue)) - .map((v, i) => ( -
setSelection(e, i)} - key={v.id} - data-selected={v.id === selectedId} - className="flex gap-3 items-center justify-end px-2 py-1 cursor-pointer hover:bg-current/10 rounded-md last:rounded-b-xl" - tabIndex={0} - role="option" - aria-selected - > - {v?.imagePath ? ( - - ) : ( - v?.icon && - React.createElement(v.icon, { - size: 16, - color: "currentColor", - className: - "inline-block mr-1 text-primary dark:text-foreground", - }) - )} - - {v.title} - -
- ))} -
-
+ {dropdown}
); } diff --git a/src/components/input/InputField.tsx b/src/components/input/InputField.tsx index cede8bb..2cfc971 100644 --- a/src/components/input/InputField.tsx +++ b/src/components/input/InputField.tsx @@ -1,48 +1,38 @@ -'use client'; +"use client"; -import React from 'react' -import Tooltip from '../utils/Tooltip'; -import clsx from 'clsx'; +import clsx from "clsx"; +import React from "react"; +import Tooltip from "../utils/Tooltip"; type Props = { - htmlFor: string; - labelText?: React.ReactNode; - value?: string; - valid?: boolean - inputClassName?: string; + htmlFor: string; + labelText?: React.ReactNode; + value?: string; + valid?: boolean; + inputClassName?: string; } & Omit, "id">; +const InputField = React.forwardRef(({ onChange, htmlFor, labelText, children, valid = true, className, inputClassName, ...inputProps }, ref) => { + return ( +
+ -const InputField = React.forwardRef( - ({ onChange, htmlFor, labelText, children, valid = true, className, inputClassName, ...inputProps }, ref) => { - return ( -
- + +
+ ); +}); - -
- ) - } -) +InputField.displayName = "InputField"; -InputField.displayName = 'InputField' - -export default InputField \ No newline at end of file +export default InputField; diff --git a/src/features/auth/components/StepEnterNumber.tsx b/src/features/auth/components/StepEnterNumber.tsx index aff0603..d024a75 100644 --- a/src/features/auth/components/StepEnterNumber.tsx +++ b/src/features/auth/components/StepEnterNumber.tsx @@ -1,127 +1,112 @@ -'use client' +"use client"; -import Button from '@/components/button/PrimaryButton' -import InputField from '@/components/input/InputField' -import { AUTH_PAGE_ELEMENT, AUTH_STEP } from '@/enums' -import { LoginOTPRequestType } from '@/app/auth/login/types/Types' -import Image from 'next/image' -import React, { useState } from 'react' -import type { ChangeEvent } from 'react' -import { useTranslation } from 'react-i18next' -import { glassSurfaceFlat } from '@/lib/styles/glassSurface' -import { useFormik } from 'formik' -import * as Yup from 'yup' -import { useOtpRequest } from '@/app/auth/login/hooks/useAuthData' -import { useParams } from 'next/navigation' -import { toast } from '@/components/Toast' -import StepOtp from './StepOtp' -import { extractErrorMessage } from '@/lib/func' -import { useCountdown } from '@/hooks/useCountdown' +import { useOtpRequest } from "@/app/auth/login/hooks/useAuthData"; +import { LoginOTPRequestType } from "@/app/auth/login/types/Types"; +import Button from "@/components/button/PrimaryButton"; +import InputField from "@/components/input/InputField"; +import { toast } from "@/components/Toast"; +import { AUTH_PAGE_ELEMENT, AUTH_STEP } from "@/enums"; +import { useCountdown } from "@/hooks/useCountdown"; +import { extractErrorMessage } from "@/lib/func"; +import { glassSurfaceFlat } from "@/lib/styles/glassSurface"; +import { useFormik } from "formik"; +import Image from "next/image"; +import { useParams } from "next/navigation"; +import type { ChangeEvent } from "react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as Yup from "yup"; +import StepOtp from "./StepOtp"; type StepEnterNumberProps = { - pending?: boolean - onChange?: (event: ChangeEvent) => void - value?: string -} + pending?: boolean; + onChange?: (event: ChangeEvent) => void; + value?: string; +}; function StepEnterNumber(_props: StepEnterNumberProps = {}) { - void _props - - const { t } = useTranslation('auth') - const { name } = useParams() - const [step, setStep] = useState(AUTH_STEP.ENTER_NUMBER) - const { mutate: mutateOtpRequest, isPending } = useOtpRequest() - const { timerRunning, secondsLeft, restart } = useCountdown( - step === AUTH_STEP.ENTER_OTP, - 120 - ) + void _props; + const { t } = useTranslation("auth"); + const { name } = useParams(); + const [step, setStep] = useState(AUTH_STEP.ENTER_NUMBER); + const { mutate: mutateOtpRequest, isPending } = useOtpRequest(); + const { timerRunning, secondsLeft, restart } = useCountdown(step === AUTH_STEP.ENTER_OTP, 120); const formik = useFormik({ initialValues: { - phone: '', - slug: '', + phone: "", + slug: "", }, validationSchema: Yup.object({ - phone: Yup.string().required('Errors.PhoneNumberRequired').matches(/^09\d{9}$/, 'Errors.PhoneNumberFormat'), + phone: Yup.string() + .required("Errors.PhoneNumberRequired") + .matches(/^09\d{9}$/, "Errors.PhoneNumberFormat"), }), onSubmit: (values) => { if (name) { - values.slug = name as string + values.slug = name as string; mutateOtpRequest(values, { onSuccess: () => { - toast(t('otp_sent'), 'success') - setStep(AUTH_STEP.ENTER_OTP) + toast(t("otp_sent"), "success"); + setStep(AUTH_STEP.ENTER_OTP); }, onError: (error) => { - toast(extractErrorMessage(error), 'error') - - } - }) + toast(extractErrorMessage(error), "error"); + }, + }); } - } - }) + }, + }); const handleResendOtp = () => { - if (timerRunning || !name) return + if (timerRunning || !name) return; mutateOtpRequest( { phone: formik.values.phone, slug: name as string }, { onSuccess: () => { - toast(t('otp_sent'), 'success') - restart() + toast(t("otp_sent"), "success"); + restart(); }, onError: (error) => { - toast(extractErrorMessage(error), 'error') + toast(extractErrorMessage(error), "error"); }, - } - ) - } + }, + ); + }; return ( <> - login banner -
-
-
- { - step === AUTH_STEP.ENTER_NUMBER && ( - <> -
{t('Enter.Heading')}
-

{t('Enter.Description')}

- - ) - } - { - step === AUTH_STEP.ENTER_OTP && ( - <> -
{t('OTP.Heading')}
-

{t('OTP.Description', { phoneNumber: formik.values.phone })}

- - ) - } + login banner +
+
+
+ {step === AUTH_STEP.ENTER_NUMBER && ( + <> +
{t("Enter.Heading")}
+

{t("Enter.Description")}

+ + )} + {step === AUTH_STEP.ENTER_OTP && ( + <> +
{t("OTP.Heading")}
+

{t("OTP.Description", { phoneNumber: formik.values.phone })}

+ + )}
{step === AUTH_STEP.ENTER_NUMBER && ( )} {step === AUTH_STEP.ENTER_OTP && ( @@ -135,22 +120,14 @@ function StepEnterNumber(_props: StepEnterNumberProps = {}) { /> )}
- { - step === AUTH_STEP.ENTER_NUMBER && ( - - ) - } + {step === AUTH_STEP.ENTER_NUMBER && ( + + )}
- ) + ); } -export default StepEnterNumber +export default StepEnterNumber;