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