added loading state to toolbar

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-13 16:51:05 +00:00
parent e0f1859f58
commit 0c080595a4
4 changed files with 94 additions and 14 deletions

View file

@ -5,6 +5,7 @@ import {
ChartBarIcon,
HomeModernIcon,
BuildingOffice2Icon,
ArrowPathIcon,
} from "@heroicons/react/24/outline";
import {
NavigationMenu,
@ -16,7 +17,7 @@ import AddNewDropDown from "./AddNew";
import YourProjectsDropdown from "./YourProjectsDropdown";
import UploadCsvModal from "@/app/portfolio/[slug]/components/UploadCsvModal";
import { ScenarioSelect } from "@/app/db/schema/recommendations";
import { useState } from "react";
import { useState, useTransition } from "react";
import { useRouter, usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
@ -29,6 +30,16 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
const router = useRouter();
const pathname = usePathname();
const [modalIsOpen, setModalIsOpen] = useState(false);
const [loadingHref, setLoadingHref] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const handleNav = (href: string) => {
if (pathname === href) return;
setLoadingHref(href);
startTransition(() => {
router.push(href);
});
};
const navItems = [
{
@ -67,11 +78,12 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
<NavigationMenuList className="flex-wrap">
{navItems.map(({ label, icon: Icon, href, match }) => {
const isActive = match(pathname);
const isLoading = loadingHref === href && isPending;
return (
<NavigationMenuItem key={label}>
<button
onClick={() => router.push(href)}
onClick={() => handleNav(href)}
className={cn(
"relative flex items-center rounded-md text-xs font-medium p-[3px]",
isActive &&
@ -86,7 +98,11 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
: "bg-gray-50 text-gray-800 hover:bg-midblue hover:text-gray-100"
)}
>
<Icon className="h-4 w-4 mr-2" />
{isLoading ? (
<ArrowPathIcon className="h-4 w-4 mr-2 animate-spin" />
) : (
<Icon className="h-4 w-4 mr-2" />
)}
{label}
</div>
</button>
@ -101,11 +117,12 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
/>
{SettingsItems.map(({ label, icon: Icon, href, match }) => {
const isActive = match(pathname);
const isLoading = loadingHref === href && isPending;
return (
<NavigationMenuItem key={label}>
<button
onClick={() => router.push(href)}
onClick={() => handleNav(href)}
className={cn(
"relative flex items-center rounded-md text-xs font-medium p-[3px]",
isActive &&
@ -120,7 +137,11 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
: "bg-gray-50 text-gray-800 hover:bg-midblue hover:text-gray-100"
)}
>
<Icon className="h-4 w-4 mr-2" />
{isLoading ? (
<ArrowPathIcon className="h-4 w-4 mr-2 animate-spin" />
) : (
<Icon className="h-4 w-4 mr-2" />
)}
{label}
</div>
</button>

View file

@ -159,13 +159,16 @@ function EpcDropdown({
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
function handleScroll() {
function handleScroll(e: Event) {
if (dropdownRef.current && dropdownRef.current.contains(e.target as Node)) return;
setOpen(false);
}
if (open) {
@ -231,7 +234,7 @@ function EpcDropdown({
</button>
{open && (
<div style={dropdownStyle} className="rounded-md border border-gray-200 bg-white shadow-lg py-1">
<div ref={dropdownRef} style={dropdownStyle} className="rounded-md border border-gray-200 bg-white shadow-lg py-1">
{EPC_LETTERS.map((l) => {
const isSelected = selected.includes(l);
return (
@ -283,6 +286,7 @@ function EnumMultiDropdown({
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleOutside(e: MouseEvent) {
@ -290,7 +294,8 @@ function EnumMultiDropdown({
setOpen(false);
}
}
function handleScroll() {
function handleScroll(e: Event) {
if (dropdownRef.current && dropdownRef.current.contains(e.target as Node)) return;
setOpen(false);
}
if (open) {
@ -346,7 +351,7 @@ function EnumMultiDropdown({
</button>
{open && (
<div style={dropdownStyle} className="rounded-md border border-gray-200 bg-white shadow-lg py-1">
<div ref={dropdownRef} style={dropdownStyle} className="rounded-md border border-gray-200 bg-white shadow-lg py-1">
{options.map((opt) => {
const isSelected = selectedLabels.includes(opt.label);
return (

View file

@ -30,6 +30,7 @@ import {
Updater,
PaginationState,
} from "@tanstack/react-table";
import { Tooltip } from "./Tooltip";
import {
DropdownMenu,
@ -510,7 +511,7 @@ export default function PropertyTable({
<span className="text-xs text-slate-500">
Showing{" "}
<span className="font-bold text-primary">
{filteredTotal.toLocaleString()}
{tableData.length.toLocaleString()}
</span>{" "}
of{" "}
<span className="font-bold text-primary">
@ -590,17 +591,16 @@ export default function PropertyTable({
{/* Export */}
{filteredTotal > EXPORT_LIMIT ? (
<div
title={`Export is available for up to ${EXPORT_LIMIT.toLocaleString()} properties. Refine your filters to enable it.`}
>
<Tooltip content={`Export is limited to ${EXPORT_LIMIT.toLocaleString()} properties. Refine your filters to enable it.`}>
<button
disabled
className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-slate-400 cursor-not-allowed opacity-60"
>
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
Export
<span className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full bg-amber-400 text-white text-[9px] font-black leading-none">!</span>
</button>
</div>
</Tooltip>
) : (
<button
onClick={() => exportToCsv(tableData)}

View file

@ -0,0 +1,54 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
interface TooltipProps {
content: React.ReactNode;
children: React.ReactElement;
}
export function Tooltip({ content, children }: TooltipProps) {
const [visible, setVisible] = useState(false);
const [style, setStyle] = useState<React.CSSProperties>({});
const triggerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!visible || !triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
setStyle({
position: "fixed",
top: rect.top - 8,
left: rect.left + rect.width / 2,
transform: "translate(-50%, -100%)",
zIndex: 9999,
});
}, [visible]);
return (
<>
<div
ref={triggerRef}
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
className="inline-flex"
>
{children}
</div>
{visible &&
createPortal(
<div style={style} className="pointer-events-none">
<div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 max-w-[220px] text-center leading-snug shadow-lg">
{content}
</div>
{/* Arrow */}
<div className="flex justify-center">
<div className="w-0 h-0 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-gray-900" />
</div>
</div>,
document.body
)}
</>
);
}