175 lines
5.5 KiB
TypeScript
175 lines
5.5 KiB
TypeScript
"use client"
|
|
|
|
import { memo, useEffect, useLayoutEffect, useRef, useState } from "react"
|
|
import { Icon } from "@iconify/react"
|
|
import { inboxTabActiveAccentColor } from "@/lib/inbox-category-tabs"
|
|
import { inboxTabShowsInactiveMeta } from "@/lib/mail-url"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
export type CompactInboxCategoryTab = {
|
|
id: string
|
|
label: string
|
|
icon: string
|
|
badgeColor: string
|
|
}
|
|
|
|
const TAB_ICON_CLASS = "h-4 w-4 shrink-0"
|
|
|
|
function inboxTabBadgeDotClass(badgeColor: string) {
|
|
return cn(
|
|
"absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-white",
|
|
badgeColor
|
|
)
|
|
}
|
|
|
|
type CompactInboxCategoryTabsProps = {
|
|
tabs: readonly CompactInboxCategoryTab[]
|
|
activeTabId: string
|
|
unseenInTabById: Record<string, number>
|
|
onTabClick: (tabId: string) => void
|
|
}
|
|
|
|
export const CompactInboxCategoryTabs = memo(function CompactInboxCategoryTabs({
|
|
tabs,
|
|
activeTabId,
|
|
unseenInTabById,
|
|
onTabClick,
|
|
}: CompactInboxCategoryTabsProps) {
|
|
const barRef = useRef<HTMLDivElement>(null)
|
|
const [indicator, setIndicator] = useState({ x: 0, width: 0, ready: false })
|
|
const [optimisticTabId, setOptimisticTabId] = useState<string | null>(null)
|
|
const displayedTabId = optimisticTabId ?? activeTabId
|
|
|
|
useEffect(() => {
|
|
setOptimisticTabId(null)
|
|
}, [activeTabId])
|
|
|
|
useLayoutEffect(() => {
|
|
const bar = barRef.current
|
|
if (!bar) return
|
|
|
|
let raf = 0
|
|
const measure = () => {
|
|
cancelAnimationFrame(raf)
|
|
raf = requestAnimationFrame(() => {
|
|
const activeBtn = bar.querySelector<HTMLButtonElement>(
|
|
`[data-inbox-tab="${displayedTabId}"]`
|
|
)
|
|
if (!activeBtn) return
|
|
const barRect = bar.getBoundingClientRect()
|
|
const btnRect = activeBtn.getBoundingClientRect()
|
|
const x = btnRect.left - barRect.left
|
|
const width = btnRect.width
|
|
setIndicator((prev) =>
|
|
prev.ready && prev.x === x && prev.width === width
|
|
? prev
|
|
: { x, width, ready: true }
|
|
)
|
|
})
|
|
}
|
|
|
|
measure()
|
|
|
|
const onResize = () => measure()
|
|
window.addEventListener("resize", onResize, { passive: true })
|
|
const ro =
|
|
typeof ResizeObserver !== "undefined"
|
|
? new ResizeObserver(onResize)
|
|
: null
|
|
ro?.observe(bar)
|
|
|
|
return () => {
|
|
cancelAnimationFrame(raf)
|
|
window.removeEventListener("resize", onResize)
|
|
ro?.disconnect()
|
|
}
|
|
}, [displayedTabId, tabs])
|
|
|
|
const activeTab = tabs.find((t) => t.id === displayedTabId)
|
|
const activeAccentColor = activeTab
|
|
? inboxTabActiveAccentColor(activeTab.id, activeTab.badgeColor)
|
|
: "#0b57d0"
|
|
|
|
return (
|
|
<div ref={barRef} className="relative flex w-full min-w-0">
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
"pointer-events-none absolute bottom-0 left-0 z-[2] h-[3px] origin-left rounded-t-sm",
|
|
"will-change-[transform,width] transition-[transform,width,background-color] duration-300 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none",
|
|
indicator.ready ? "opacity-100" : "opacity-0"
|
|
)}
|
|
style={{
|
|
width: indicator.width,
|
|
transform: `translate3d(${indicator.x}px, 0, 0)`,
|
|
backgroundColor: activeAccentColor,
|
|
}}
|
|
/>
|
|
{tabs.map((tab) => {
|
|
const isActive = displayedTabId === tab.id
|
|
const accentColor = isActive
|
|
? inboxTabActiveAccentColor(tab.id, tab.badgeColor)
|
|
: undefined
|
|
const unseen = unseenInTabById[tab.id] ?? 0
|
|
const showMeta =
|
|
inboxTabShowsInactiveMeta(tab.id) && !isActive && unseen > 0
|
|
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
data-inbox-tab={tab.id}
|
|
title={!isActive ? tab.label : undefined}
|
|
aria-label={tab.label}
|
|
aria-current={isActive ? "true" : undefined}
|
|
onClick={() => {
|
|
if (tab.id !== activeTabId) setOptimisticTabId(tab.id)
|
|
onTabClick(tab.id)
|
|
}}
|
|
className={cn(
|
|
"relative z-[1] flex min-h-10 cursor-pointer items-center justify-center px-1",
|
|
"transition-colors duration-200 motion-reduce:transition-none",
|
|
isActive ? "shrink-0 flex-none" : "min-w-0 flex-1 overflow-hidden",
|
|
!isActive && "hover:bg-[#f1f3f4]"
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex h-10 items-center justify-center gap-1.5",
|
|
isActive ? "shrink-0 px-0.5" : "w-full min-w-0"
|
|
)}
|
|
>
|
|
<div className="relative inline-flex shrink-0">
|
|
<Icon
|
|
icon={tab.icon}
|
|
className={cn(
|
|
TAB_ICON_CLASS,
|
|
"transition-colors duration-200 motion-reduce:transition-none",
|
|
!isActive && "text-[#5f6368]"
|
|
)}
|
|
style={accentColor ? { color: accentColor } : undefined}
|
|
aria-hidden
|
|
/>
|
|
{showMeta && unseen > 0 ? (
|
|
<span
|
|
className={inboxTabBadgeDotClass(tab.badgeColor)}
|
|
aria-hidden
|
|
/>
|
|
) : null}
|
|
</div>
|
|
{isActive ? (
|
|
<span
|
|
className="shrink-0 whitespace-nowrap text-[13px] font-semibold leading-tight"
|
|
style={{ color: accentColor }}
|
|
>
|
|
{tab.label}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
})
|