Add Compact Inbox Category Tabs component and integrate with Email List
This commit is contained in:
parent
1fc4de1873
commit
9b17d4a904
159
components/gmail/compact-inbox-category-tabs.tsx
Normal file
159
components/gmail/compact-inbox-category-tabs.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
@ -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,11 +1732,13 @@ export function EmailList({
|
||||
|
||||
const handleCategoryInboxTabClick = useCallback(
|
||||
(tabId: string) => {
|
||||
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 l’onglet 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
|
||||
: {
|
||||
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,57 +2853,17 @@ 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"
|
||||
),
|
||||
"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">
|
||||
@ -2959,12 +2927,11 @@ export function EmailList({
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user