refactor(blocks): refine the responsive application shell
Present chat threads in a drawer on mobile and a popover on larger screens, with shared responsive triggers and automatic close after selection. Align header action icon sizing and tighten navigation spacing for the updated shell.
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
import { createDrawerHandle } from "@workspace/ui/components/drawer"
|
||||||
|
|
||||||
|
export const chatDrawerHandle = createDrawerHandle()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { DrawerTrigger } from "@workspace/ui/components/drawer"
|
||||||
|
import React from "react"
|
||||||
|
import { chatDrawerHandle } from "./chat-drawer-handle"
|
||||||
|
|
||||||
|
type ChatDrawerTriggerProps = Omit<
|
||||||
|
React.ComponentProps<typeof DrawerTrigger>,
|
||||||
|
"handle"
|
||||||
|
>
|
||||||
|
|
||||||
|
export function ChatDrawerTrigger(props: ChatDrawerTriggerProps) {
|
||||||
|
return <DrawerTrigger {...props} handle={chatDrawerHandle} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useMessage } from "@workspace/i18n"
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@workspace/ui/components/drawer"
|
||||||
|
import { ChatPopoverProps } from "./chat-popover"
|
||||||
|
import { chatMessages } from "./messages"
|
||||||
|
import { ItemGroup } from "@workspace/ui/components/item"
|
||||||
|
import { ChatThreadItem } from "./chat-thread-item"
|
||||||
|
import { chatDrawerHandle } from "./chat-drawer-handle"
|
||||||
|
|
||||||
|
export function ChatDrawer({ threads, title }: ChatPopoverProps) {
|
||||||
|
const defaultTitle = useMessage(chatMessages.title)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer handle={chatDrawerHandle}>
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>
|
||||||
|
{title ?? defaultTitle}({threads.length})
|
||||||
|
</DrawerTitle>
|
||||||
|
<DrawerDescription className="sr-only" />
|
||||||
|
</DrawerHeader>
|
||||||
|
<ItemGroup className="no-scrollbar scroll-fade gap-0! overflow-y-auto p-4">
|
||||||
|
{threads.map((thread) => (
|
||||||
|
<ChatThreadItem
|
||||||
|
key={thread.name}
|
||||||
|
thread={thread}
|
||||||
|
onClick={() => chatDrawerHandle.close()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ItemGroup>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,11 +34,15 @@ export function ChatPopover({ threads, title }: ChatPopoverProps) {
|
|||||||
<PopoverTitle className="text-lg">
|
<PopoverTitle className="text-lg">
|
||||||
{title ?? defaultTitle}({threads.length})
|
{title ?? defaultTitle}({threads.length})
|
||||||
</PopoverTitle>
|
</PopoverTitle>
|
||||||
<PopoverDescription />
|
<PopoverDescription className="sr-only" />
|
||||||
</PopoverHeader>
|
</PopoverHeader>
|
||||||
<ItemGroup className="no-scrollbar scroll-fade gap-0! overflow-y-auto">
|
<ItemGroup className="no-scrollbar scroll-fade gap-0! overflow-y-auto">
|
||||||
{threads.map((thread) => (
|
{threads.map((thread) => (
|
||||||
<ChatThreadItem key={thread.name} thread={thread} />
|
<ChatThreadItem
|
||||||
|
key={thread.name}
|
||||||
|
thread={thread}
|
||||||
|
onClick={() => chatPopoverHandle.close()}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useIsMobile } from "@workspace/ui/hooks/use-breakpoint"
|
||||||
|
import { ChatDrawerTrigger } from "./chat-drawer-trigger"
|
||||||
|
import { ChatPopoverTrigger } from "./chat-popover-trigger"
|
||||||
|
import { ComponentRenderFn, HTMLProps } from "@base-ui/react"
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
disabled: boolean
|
||||||
|
open: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatThreadTriggerProps = {
|
||||||
|
className?: string
|
||||||
|
render?: React.ReactElement | ComponentRenderFn<HTMLProps, State> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatThreadTrigger({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
}: ChatThreadTriggerProps) {
|
||||||
|
return useIsMobile() ? (
|
||||||
|
<ChatDrawerTrigger className={className} render={render} />
|
||||||
|
) : (
|
||||||
|
<ChatPopoverTrigger className={className} render={render} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useIsMobile } from "@workspace/ui/hooks/use-breakpoint"
|
||||||
|
import { ChatThread } from "./types"
|
||||||
|
import { ChatDrawer } from "./chat-drawer"
|
||||||
|
import { ChatPopover } from "./chat-popover"
|
||||||
|
|
||||||
|
export interface ChatThreadsProps {
|
||||||
|
threads: readonly ChatThread[]
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatThreads(props: ChatThreadsProps) {
|
||||||
|
return useIsMobile() ? <ChatDrawer {...props} /> : <ChatPopover {...props} />
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
BellIcon,
|
BellIcon,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -9,9 +10,9 @@ import { Button } from "@workspace/ui/components/button"
|
|||||||
import { Kbd } from "@workspace/ui/components/kbd"
|
import { Kbd } from "@workspace/ui/components/kbd"
|
||||||
import { useIsTablet } from "@workspace/ui/hooks/use-breakpoint"
|
import { useIsTablet } from "@workspace/ui/hooks/use-breakpoint"
|
||||||
import { cn } from "@workspace/ui/lib/utils"
|
import { cn } from "@workspace/ui/lib/utils"
|
||||||
import { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react"
|
import { Separator } from "@workspace/ui/components/separator"
|
||||||
|
|
||||||
import { ChatPopover, ChatPopoverTrigger, type ChatThread } from "../chats"
|
import { type ChatThread } from "../chats"
|
||||||
import {
|
import {
|
||||||
MobileNavigationSheet,
|
MobileNavigationSheet,
|
||||||
MobileNavigationSheetTrigger,
|
MobileNavigationSheetTrigger,
|
||||||
@@ -31,8 +32,9 @@ import {
|
|||||||
import { useSidebarState } from "./sidebar-state"
|
import { useSidebarState } from "./sidebar-state"
|
||||||
import { UserMenu, UserMenuTrigger } from "./user-menu"
|
import { UserMenu, UserMenuTrigger } from "./user-menu"
|
||||||
import { AppBreadcrumb } from "./app-breadcrumb"
|
import { AppBreadcrumb } from "./app-breadcrumb"
|
||||||
import { Separator } from "@workspace/ui/components/separator"
|
|
||||||
import { AppQueryIndicator } from "./app-query-indicator"
|
import { AppQueryIndicator } from "./app-query-indicator"
|
||||||
|
import { ChatThreadTrigger } from "../chats/chat-thread-trigger"
|
||||||
|
import { ChatThreads } from "../chats/chat-threads"
|
||||||
|
|
||||||
export interface AppLayoutProps {
|
export interface AppLayoutProps {
|
||||||
chatThreads: readonly ChatThread[]
|
chatThreads: readonly ChatThread[]
|
||||||
@@ -54,7 +56,7 @@ export function AppLayout({
|
|||||||
<MobileNavigationSheet groups={navigationGroups} />
|
<MobileNavigationSheet groups={navigationGroups} />
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
<NotificationCenterSheet {...notifications} />
|
<NotificationCenterSheet {...notifications} />
|
||||||
<ChatPopover threads={chatThreads} />
|
<ChatThreads threads={chatThreads} />
|
||||||
<div className="isolate min-h-svh w-svw">
|
<div className="isolate min-h-svh w-svw">
|
||||||
<AppHeader actions={headerActions} />
|
<AppHeader actions={headerActions} />
|
||||||
<div className="relative z-1 flex min-h-svh w-svw items-start">
|
<div className="relative z-1 flex min-h-svh w-svw items-start">
|
||||||
@@ -90,10 +92,7 @@ function AppHeader({ actions }: { actions?: React.ReactNode }) {
|
|||||||
gradientUnits="userSpaceOnUse"
|
gradientUnits="userSpaceOnUse"
|
||||||
>
|
>
|
||||||
<stop stopColor="var(--palette-primary-dark)"></stop>
|
<stop stopColor="var(--palette-primary-dark)"></stop>
|
||||||
<stop
|
<stop offset="1" stopColor="var(--palette-primary-main)" />
|
||||||
offset="1"
|
|
||||||
stopColor="var(--palette-primary-main)"
|
|
||||||
></stop>
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient
|
<linearGradient
|
||||||
id="_r_5g_-2"
|
id="_r_5g_-2"
|
||||||
@@ -103,11 +102,8 @@ function AppHeader({ actions }: { actions?: React.ReactNode }) {
|
|||||||
y2="384"
|
y2="384"
|
||||||
gradientUnits="userSpaceOnUse"
|
gradientUnits="userSpaceOnUse"
|
||||||
>
|
>
|
||||||
<stop stopColor="var(--palette-primary-light)"></stop>
|
<stop stopColor="var(--palette-primary-light)" />
|
||||||
<stop
|
<stop offset="1" stopColor="var(--palette-primary-main)" />
|
||||||
offset="1"
|
|
||||||
stopColor="var(--palette-primary-main)"
|
|
||||||
></stop>
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient
|
<linearGradient
|
||||||
id="_r_5g_-3"
|
id="_r_5g_-3"
|
||||||
@@ -118,28 +114,25 @@ function AppHeader({ actions }: { actions?: React.ReactNode }) {
|
|||||||
gradientUnits="userSpaceOnUse"
|
gradientUnits="userSpaceOnUse"
|
||||||
>
|
>
|
||||||
<stop stopColor="var(--palette-primary-light)"></stop>
|
<stop stopColor="var(--palette-primary-light)"></stop>
|
||||||
<stop
|
<stop offset="1" stopColor="var(--palette-primary-main)" />
|
||||||
offset="1"
|
|
||||||
stopColor="var(--palette-primary-main)"
|
|
||||||
></stop>
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<path
|
<path
|
||||||
fill="url(#_r_5g_-1)"
|
fill="url(#_r_5g_-1)"
|
||||||
d="M86.352 246.358C137.511 214.183 161.836 245.017 183.168 285.573C165.515 317.716 153.837 337.331 148.132 344.418C137.373 357.788 125.636 367.911 111.202 373.752C80.856 388.014 43.132 388.681 14 371.048L86.352 246.358Z"
|
d="M86.352 246.358C137.511 214.183 161.836 245.017 183.168 285.573C165.515 317.716 153.837 337.331 148.132 344.418C137.373 357.788 125.636 367.911 111.202 373.752C80.856 388.014 43.132 388.681 14 371.048L86.352 246.358Z"
|
||||||
></path>
|
/>
|
||||||
<path
|
<path
|
||||||
fill="url(#_r_5g_-2)"
|
fill="url(#_r_5g_-2)"
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
clipRule="evenodd"
|
clipRule="evenodd"
|
||||||
d="M444.31 229.726C398.04 148.77 350.21 72.498 295.267 184.382C287.751 198.766 282.272 226.719 270 226.719V226.577C257.728 226.577 252.251 198.624 244.735 184.24C189.79 72.356 141.96 148.628 95.689 229.584C92.207 235.69 88.862 241.516 86 246.58C192.038 179.453 183.11 382.247 270 383.858V384C356.891 382.389 347.962 179.595 454 246.72C451.139 241.658 447.794 235.832 444.31 229.726Z"
|
d="M444.31 229.726C398.04 148.77 350.21 72.498 295.267 184.382C287.751 198.766 282.272 226.719 270 226.719V226.577C257.728 226.577 252.251 198.624 244.735 184.24C189.79 72.356 141.96 148.628 95.689 229.584C92.207 235.69 88.862 241.516 86 246.58C192.038 179.453 183.11 382.247 270 383.858V384C356.891 382.389 347.962 179.595 454 246.72C451.139 241.658 447.794 235.832 444.31 229.726Z"
|
||||||
></path>
|
/>
|
||||||
<path
|
<path
|
||||||
fill="url(#_r_5g_-3)"
|
fill="url(#_r_5g_-3)"
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
clipRule="evenodd"
|
clipRule="evenodd"
|
||||||
d="M450 384C476.509 384 498 362.509 498 336C498 309.491 476.509 288 450 288C423.491 288 402 309.491 402 336C402 362.509 423.491 384 450 384Z"
|
d="M450 384C476.509 384 498 362.509 498 336C498 309.491 476.509 288 450 288C423.491 288 402 309.491 402 336C402 362.509 423.491 384 450 384Z"
|
||||||
></path>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,8 +141,8 @@ function AppHeader({ actions }: { actions?: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex h-full flex-1 items-center gap-2 px-10 mobile:px-6">
|
<div className="flex h-full flex-1 items-center gap-2 px-10 mobile:px-6">
|
||||||
<MobileNavigationSheetTrigger />
|
<MobileNavigationSheetTrigger />
|
||||||
<div className="flex flex-1 items-center">
|
<div className="flex flex-1 items-center gap-2">
|
||||||
<AppQueryIndicator className="-ml-2.5" />
|
<AppQueryIndicator />
|
||||||
<Separator
|
<Separator
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
className="ms-1.5 me-4 h-3 shrink-0 self-center! last:hidden"
|
className="ms-1.5 me-4 h-3 shrink-0 self-center! last:hidden"
|
||||||
@@ -162,7 +155,7 @@ function AppHeader({ actions }: { actions?: React.ReactNode }) {
|
|||||||
<NotificationCenterTrigger
|
<NotificationCenterTrigger
|
||||||
render={<HeaderActionButton icon={BellIcon} showIndicator />}
|
render={<HeaderActionButton icon={BellIcon} showIndicator />}
|
||||||
/>
|
/>
|
||||||
<ChatPopoverTrigger
|
<ChatThreadTrigger
|
||||||
render={
|
render={
|
||||||
<HeaderActionButton
|
<HeaderActionButton
|
||||||
icon={MessageMultiple01Icon}
|
icon={MessageMultiple01Icon}
|
||||||
@@ -210,7 +203,7 @@ function GlobalSearchTrigger() {
|
|||||||
<Icon
|
<Icon
|
||||||
data={Search01Icon}
|
data={Search01Icon}
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-event-none size-6 transition-all not-mobile:hidden",
|
"pointer-event-none size-5 transition-all not-mobile:hidden",
|
||||||
headerActionIconClassName
|
headerActionIconClassName
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -218,9 +211,7 @@ function GlobalSearchTrigger() {
|
|||||||
className="text-muted-foreground mobile:hidden"
|
className="text-muted-foreground mobile:hidden"
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
/>
|
/>
|
||||||
<Kbd className="rounded-[4px] bg-white text-black shadow-xs mobile:hidden">
|
<Kbd className="bg-white text-black shadow-xs mobile:hidden">⌘K</Kbd>
|
||||||
⌘K
|
|
||||||
</Kbd>
|
|
||||||
</HeaderActionButton>
|
</HeaderActionButton>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function AppQueryIndicator({ className }: { className?: string }) {
|
|||||||
render={
|
render={
|
||||||
<HeaderActionButton
|
<HeaderActionButton
|
||||||
aria-label={refreshLabel}
|
aria-label={refreshLabel}
|
||||||
className={cn("[&_svg]:size-4", className)}
|
className={cn("[&_svg]:size-4.5", className)}
|
||||||
disabled={!canRefresh}
|
disabled={!canRefresh}
|
||||||
icon={ReloadIcon}
|
icon={ReloadIcon}
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function HeaderActionButton({
|
|||||||
return (
|
return (
|
||||||
<Button variant="ghost" size="icon-lg" className={cn(className)} {...props}>
|
<Button variant="ghost" size="icon-lg" className={cn(className)} {...props}>
|
||||||
{icon ? (
|
{icon ? (
|
||||||
<Icon data={icon} className={cn(headerActionIconClassName)} />
|
<Icon data={icon} className={cn("size-5", headerActionIconClassName)} />
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function MobileNavigationSheetTrigger({
|
|||||||
<Icon
|
<Icon
|
||||||
data={Menu02Icon}
|
data={Menu02Icon}
|
||||||
strokeWidth={2.25}
|
strokeWidth={2.25}
|
||||||
className="size-7 [&_path]:transition-all group-hover/button:[&_path]:not-first:not-last:opacity-40 [&_path]:first:opacity-40 group-hover/button:[&_path]:first:translate-x-1 group-hover/button:[&_path]:first:opacity-100 [&_path]:last:opacity-60 group-hover/button:[&_path]:last:translate-x-2 group-hover/button:[&_path]:last:opacity-100"
|
className="size-5 [&_path]:transition-all group-hover/button:[&_path]:not-first:not-last:opacity-40 [&_path]:first:opacity-40 group-hover/button:[&_path]:first:translate-x-1 group-hover/button:[&_path]:first:opacity-100 [&_path]:last:opacity-60 group-hover/button:[&_path]:last:translate-x-2 group-hover/button:[&_path]:last:opacity-100"
|
||||||
/>
|
/>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ export function InlineNestedNavigationList({
|
|||||||
const hasNestedItems = items.some((item) => !!item.items?.length)
|
const hasNestedItems = items.some((item) => !!item.items?.length)
|
||||||
|
|
||||||
const listElement = (
|
const listElement = (
|
||||||
<div className={cn("w-full pb-1 pl-6", className)}>
|
<div className={cn("w-full pl-6", className)}>
|
||||||
<ul
|
<ul
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative space-y-1 py-1 pl-2.75",
|
"space-y relative py-px pl-2.75",
|
||||||
"before:absolute before:top-0 before:bottom-7 before:left-0",
|
"before:absolute before:top-0 before:bottom-7 before:left-0",
|
||||||
"before:-translate-x-px",
|
"before:-translate-x-px",
|
||||||
"before:w-0.5",
|
"before:w-0.5",
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export function PrimaryNavigation({
|
|||||||
<Accordion.Root
|
<Accordion.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-74 space-y-7 p-4 tablet:w-22 not-mobile:collapsed:w-22",
|
"w-74 space-y-7 p-4 tablet:w-22 not-mobile:collapsed:w-22",
|
||||||
"tablet:space-y-1 tablet:p-2 not-mobile:collapsed:space-y-1 not-mobile:collapsed:p-2",
|
"tablet:space-y tablet:p-2 not-mobile:collapsed:space-y-1 not-mobile:collapsed:p-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
keepMounted
|
keepMounted
|
||||||
@@ -175,7 +175,7 @@ export function PrimaryNavigation({
|
|||||||
<div className="mx-3 mb-2 truncate text-xs whitespace-nowrap tablet:hidden not-mobile:collapsed:hidden">
|
<div className="mx-3 mb-2 truncate text-xs whitespace-nowrap tablet:hidden not-mobile:collapsed:hidden">
|
||||||
<NavigationLabel label={group.label} />
|
<NavigationLabel label={group.label} />
|
||||||
</div>
|
</div>
|
||||||
<div className="tablet:space-y-1 not-mobile:collapsed:space-y-1">
|
<div className="tablet:space-y not-mobile:collapsed:space-y-1">
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => (
|
||||||
<PrimaryNavigationItem
|
<PrimaryNavigationItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|||||||
Reference in New Issue
Block a user