Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, type ReactNode } from 'react'
import { memo, type ReactNode, useState } from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import {
ArrowDown,
Expand All @@ -20,6 +20,11 @@ const SEARCH_ICON = (
<Search className='pointer-events-none size-[14px] shrink-0 text-[var(--text-icon)]' />
)

const FILTER_POPOVER_ANIMATION_CLASSES =
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none'

const RESOURCE_MENU_EDGE_OFFSET = 6

type SortDirection = 'asc' | 'desc'

export interface ColumnOption {
Expand Down Expand Up @@ -84,6 +89,14 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
filterTags,
extras,
}: ResourceOptionsBarProps) {
/**
* Coordinates the Filter popover and Sort menu as a single menu bar: clicking
* one while the other is open switches to it in a single click. Functional
* updates make the close→open ordering race-proof, so whichever menu the click
* targets wins regardless of which `onOpenChange` fires first.
*/
const [openMenu, setOpenMenu] = useState<'filter' | 'sort' | null>(null)

const hasContent =
search || sort || filter || onFilterToggle || extras || (filterTags && filterTags.length > 0)
if (!hasContent) return null
Expand Down Expand Up @@ -123,25 +136,50 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
Filter
</Button>
) : filter ? (
<PopoverPrimitive.Root>
<PopoverPrimitive.Trigger asChild>
<Button variant='subtle' className='px-2 py-1 text-caption'>
<ListFilter className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
Filter
</Button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Root
open={openMenu === 'filter'}
onOpenChange={(open) =>
setOpenMenu((current) => (open ? 'filter' : current === 'filter' ? null : current))
}
>
<PopoverPrimitive.Anchor asChild>
<div className='flex items-center gap-1.5'>
<PopoverPrimitive.Trigger asChild>
<Button variant='subtle' className='px-2 py-1 text-caption'>
<ListFilter className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
Filter
</Button>
</PopoverPrimitive.Trigger>
{sort && (
<SortDropdown
config={sort}
open={openMenu === 'sort'}
onOpenChange={(open) =>
setOpenMenu((current) =>
open ? 'sort' : current === 'sort' ? null : current
)
}
/>
)}
</div>
</PopoverPrimitive.Anchor>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align='start'
align='end'
alignOffset={RESOURCE_MENU_EDGE_OFFSET}
collisionPadding={6}
sideOffset={6}
className='z-50 w-fit rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
className={cn(
FILTER_POPOVER_ANIMATION_CLASSES,
'z-50 w-fit origin-[--radix-popover-content-transform-origin] rounded-xl border border-[var(--border)] bg-[var(--bg)] shadow-sm'
)}
>
{filter}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
) : null}
{sort && <SortDropdown config={sort} />}
{sort && (onFilterToggle || !filter) && <SortDropdown config={sort} />}
</div>
</div>
</div>
Expand Down Expand Up @@ -200,11 +238,19 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo
)
})

const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig }) {
interface SortDropdownProps {
config: SortConfig
/** Controlled open state — omit for standalone (uncontrolled) usage. */
open?: boolean
/** Controlled open-change handler, paired with {@link SortDropdownProps.open}. */
onOpenChange?: (open: boolean) => void
}

const SortDropdown = memo(function SortDropdown({ config, open, onOpenChange }: SortDropdownProps) {
const { options, active, onSort, onClear } = config

return (
<DropdownMenu>
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant='subtle'
Expand All @@ -222,7 +268,11 @@ const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig
Sort
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuContent
align='end'
alignOffset={RESOURCE_MENU_EDGE_OFFSET}
className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]'
>
{options.map((option) => {
const isActive = active?.column === option.id
const Icon = option.icon
Expand Down
Loading