Add Compact Inbox Category Tabs component and integrate with Email List

This commit is contained in:
R3D347HR4Y 2026-05-18 00:29:11 +02:00
parent 1fc4de1873
commit 9b17d4a904
3 changed files with 191 additions and 65 deletions

View File

@ -0,0 +1,159 @@
"use client"
import { memo, useEffect, useLayoutEffect, useRef, useState } from "react"
import { Icon } from "@iconify/react"
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])
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 bg-[#0b57d0]",
"will-change-[transform,width] transition-[transform,width] 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)`,
}}
/>
{tabs.map((tab) => {
const isActive = displayedTabId === tab.id
const isPrimaryTab = tab.id === "primary"
const unseen = unseenInTabById[tab.id] ?? 0
const showMeta = !isPrimaryTab && !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-[#0b57d0]" : "text-[#5f6368]"
)}
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 text-[#0b57d0]">
{tab.label}
</span>
) : null}
</div>
</button>
)
})}
</div>
)
})

View File

@ -1,6 +1,7 @@
"use client"
import {
startTransition,
useCallback,
useEffect,
useLayoutEffect,
@ -91,6 +92,7 @@ import {
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import { CompactInboxCategoryTabs } from "@/components/gmail/compact-inbox-category-tabs"
import { cn } from "@/lib/utils"
import { labelPillTextClassForTailwindBgUtility } from "@/lib/label-pill-contrast"
import {
@ -1730,10 +1732,12 @@ export function EmailList({
const handleCategoryInboxTabClick = useCallback(
(tabId: string) => {
onMailRouteNavigate({
inboxTab: tabId,
page: 1,
mailId: null,
startTransition(() => {
onMailRouteNavigate({
inboxTab: tabId,
page: 1,
mailId: null,
})
})
},
[onMailRouteNavigate]
@ -1976,6 +1980,10 @@ export function EmailList({
const listToolbarMode = splitView || !isViewMode
/** xs + split : icône (+ point si non lus) ; libellé uniquement sur longlet actif. */
const compactInboxTabs = isXs || splitView
const activeInboxTabId = useMemo(
() => normalizeInboxTabSegment(inboxTab),
[inboxTab]
)
const openMailToolbar = (showBack: boolean) => (
<TooltipProvider delayDuration={400}>
@ -2817,23 +2825,23 @@ export function EmailList({
{selectedFolder === "inbox" && (
<div className="relative z-10 w-full shrink-0 bg-white after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-px after:bg-[#dadce0]">
{listToolbarMode && (
{listToolbarMode &&
(compactInboxTabs ? (
<CompactInboxCategoryTabs
tabs={inboxTabBarItems}
activeTabId={activeInboxTabId}
unseenInTabById={unseenInTabById}
onTabClick={handleCategoryInboxTabClick}
/>
) : (
<div
className={cn(
"w-full min-w-0",
compactInboxTabs ? "flex" : "grid max-w-[1260px]"
)}
style={
compactInboxTabs
? undefined
: {
gridTemplateColumns: `repeat(${inboxTabBarItems.length}, minmax(0, 1fr))`,
}
}
className="grid w-full min-w-0 max-w-[1260px]"
style={{
gridTemplateColumns: `repeat(${inboxTabBarItems.length}, minmax(0, 1fr))`,
}}
>
{inboxTabBarItems.map((tab) => {
const inboxTabNorm = normalizeInboxTabSegment(inboxTab)
const isActive = inboxTabNorm === tab.id
const isActive = activeInboxTabId === tab.id
const isPrimaryTab = tab.id === "primary"
const unseen = unseenInTabById[tab.id] ?? 0
const senderLine = tabUnseenSenderLineById[tab.id] ?? ""
@ -2845,58 +2853,18 @@ export function EmailList({
<button
key={tab.id}
type="button"
title={compactInboxTabs && !isActive ? tab.label : undefined}
aria-label={tab.label}
aria-current={isActive ? "true" : undefined}
onClick={() => handleCategoryInboxTabClick(tab.id)}
className={cn(
"relative z-[1] flex cursor-pointer transition-colors",
compactInboxTabs
? cn(
"min-h-10 items-center justify-center px-1",
isActive
? "shrink-0 flex-none"
: "min-w-0 flex-1 overflow-hidden"
)
: cn(
"min-w-0 w-full overflow-hidden max-sm:min-h-10 max-sm:items-center max-sm:justify-center",
"sm:min-h-14 sm:items-center sm:py-2 sm:text-left"
),
"min-w-0 w-full overflow-hidden max-sm:min-h-10 max-sm:items-center max-sm:justify-center",
"sm:min-h-14 sm:items-center sm:py-2 sm:text-left",
isActive && "shadow-[inset_0_-3px_0_0_#0b57d0]",
!isActive && "hover:bg-[#f1f3f4]"
)}
>
{compactInboxTabs ? (
<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(
CATEGORY_TAB_ICON_CLASS,
isActive ? "text-[#0b57d0]" : "text-[#5f6368]"
)}
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 text-[#0b57d0]">
{tab.label}
</span>
) : null}
</div>
) : (
<>
<>
<div className="flex h-10 w-full items-center justify-center sm:hidden">
<div className="relative inline-flex shrink-0">
<Icon
@ -2958,13 +2926,12 @@ export function EmailList({
) : null}
</div>
</div>
</>
)}
</>
</button>
)
})}
</div>
)}
))}
</div>
)}

File diff suppressed because one or more lines are too long