Skip to content
Merged
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
Expand Up @@ -75,7 +75,7 @@ export const RemoteVirtualizedCommand = <T,>({
const virtualizer = useVirtualizer({
count: filteredValues.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
estimateSize: () => 44,
});

return (
Expand Down Expand Up @@ -104,7 +104,9 @@ export const RemoteVirtualizedCommand = <T,>({
<CommandList
ref={parentRef}
style={{
height: isEmpty ? 'auto' : height,
height: isEmpty
? 'auto'
: `min(${height}, ${virtualizer.getTotalSize() + 8}px)`,
width: '100%',
overflow: 'auto',
}}
Expand All @@ -119,6 +121,7 @@ export const RemoteVirtualizedCommand = <T,>({
>
<VirtualizedItems
virtualItems={virtualizer.getVirtualItems()}
measureElement={virtualizer.measureElement}
filteredValues={filteredValues}
formatLabel={formatLabel}
onSelect={handleSelect}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,9 @@ describe('VirtualizedItems', () => {
const secondItem = screen.getByText('ITEM-B').closest('[cmdk-item]');

expect(firstItem).toHaveStyle({
height: '35px',
transform: 'translateY(0px)',
});
expect(secondItem).toHaveStyle({
height: '35px',
transform: 'translateY(35px)',
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { CommandItem } from '@/components/ui/command';

interface Props {
virtualItems: VirtualItem[];
measureElement?: (element: HTMLDivElement | null) => void;
filteredValues: string[];
formatLabel: (value: string) => string;
onSelect: (value: string) => void;
}

export const VirtualizedItems = ({
virtualItems,
measureElement,
filteredValues,
formatLabel,
onSelect,
Expand All @@ -23,10 +25,11 @@ export const VirtualizedItems = ({
return (
<CommandItem
key={value}
data-index={virtualItem.index}
ref={measureElement}
value={value}
className='absolute top-0 left-0 w-full bg-transparent data-[selected=true]:bg-white/5'
className='absolute top-0 left-0 min-h-11 w-full bg-transparent wrap-anywhere whitespace-normal data-[selected=true]:bg-white/5'
style={{
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
onSelect={onSelect}
Expand Down
7 changes: 5 additions & 2 deletions src/components/ui/dropdown-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,13 @@ function DropdownMenuTrigger({
function DropdownMenuContent({
className,
sideOffset = 4,
portalContainer,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content> & {
portalContainer?: HTMLElement | null;
}) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Portal container={portalContainer ?? undefined}>
<DropdownMenuPrimitive.Content
data-slot='dropdown-menu-content'
sideOffset={sideOffset}
Expand Down
15 changes: 11 additions & 4 deletions src/components/virtualized-command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const VirtualizedCommand = ({
const virtualizer = useVirtualizer({
count: filteredOptions.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
estimateSize: () => 44,
});

const virtualItems = virtualizer.getVirtualItems();
Expand All @@ -69,7 +69,13 @@ export const VirtualizedCommand = ({
{!searchValue && beforeItems}
<CommandList
ref={parentRef}
style={{ height, width: '100%', overflow: 'auto' }}
style={{
height: filteredOptions.length
? `min(${height}, ${virtualizer.getTotalSize() + 8}px)`
: 'auto',
width: '100%',
overflow: 'auto',
}}
>
<CommandEmpty>No item found.</CommandEmpty>
<CommandGroup>
Expand All @@ -86,10 +92,11 @@ export const VirtualizedCommand = ({
return (
<CommandItem
key={option.value}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
value={option.value}
className='absolute top-0 left-0 w-full bg-transparent data-[selected=true]:bg-white/5'
className='absolute top-0 left-0 min-h-11 w-full bg-transparent wrap-anywhere whitespace-normal data-[selected=true]:bg-white/5'
style={{
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
onSelect={handleSelect}
Expand Down
4 changes: 2 additions & 2 deletions src/features/filters/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ To add a new filter icon: add entry to `filterIconMap` object.
```
filters-aside/ # Sidebar container
├── active-filters/ # Currently applied filters (removable chips)
├── suggested-filters/ # Quick-select suggested filters
└── more-filters/ # "More filters" dropdown for additional options
├── suggested-filters/ # All unapplied filters, organized by group
└── filter-overlay.tsx # Screen-bounded option lists; kept inside mobile drawer
```

### Suspense Pattern
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
} from '@/features/filters/components/filter-overlay';
import { MappedFilterIcon } from '@/features/filters/components/mapped-filter-icon';
import { FILTER_DROPDOWN_CONTENT_CLASS } from '@/features/filters/constants';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
} from '@/features/filters/components/filter-overlay';
import { useFilterQueryState } from '@/features/filters/hooks';
import { MappedFilterIcon } from '@/features/filters/components/mapped-filter-icon';
import {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
} from '@/features/filters/components/filter-overlay';
import { Slider } from '@/components/ui/slider';
import type { RangeFilterConfig } from '@/features/filters/schemas';
import { MappedFilterIcon } from '@/features/filters/components/mapped-filter-icon';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
} from '@/features/filters/components/filter-overlay';
import { RemoteVirtualizedCommand } from '@/components/remote-virtualized-command';
import { MappedFilterIcon } from '@/features/filters/components/mapped-filter-icon';
import { FILTER_POPOVER_CONTENT_CLASS } from '@/features/filters/constants';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
} from '@/features/filters/components/filter-overlay';
import { CommandGroup, CommandItem } from '@/components/ui/command';
import { VirtualizedCommand } from '@/components/virtualized-command';
import { MappedFilterIcon } from '@/features/filters/components/mapped-filter-icon';
Expand Down Expand Up @@ -50,7 +50,9 @@ export const ActiveFiltersSearch = ({ label, paramKey, options }: Props) => {
);

const selectedOptions = (filterParam?.split(',') || []).map((paramValue) => ({
label: capitalizeSlug(paramValue),
label:
options.find((option) => option.value === paramValue)?.label ??
capitalizeSlug(paramValue),
value: paramValue,
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,16 @@ export const ActiveFilterTrigger = ({
...props
}: Props) => {
const labelText = usePrevious(label, !isPending);
const removeLabel = `Remove ${tooltipLabel ?? label} filter`;

return (
<ButtonGroup>
<ButtonGroup className='max-w-full'>
<Tooltip>
<TooltipTrigger asChild>
<Button
size='xs'
variant='secondary'
className='group flex h-7 items-center gap-1.5 border-none px-2 pr-3.5 [&_svg]:text-neutral-400'
className='group flex min-h-11 min-w-0 items-center gap-1.5 border-none px-2 pr-3.5 whitespace-normal sm:min-h-7 [&_svg]:text-neutral-400'
disabled={isPending}
{...props}
>
Expand All @@ -56,7 +57,8 @@ export const ActiveFilterTrigger = ({
<Button
size='icon-sm'
variant='secondary'
className='h-7 px-2'
className='min-h-11 min-w-11 px-2 sm:min-h-7 sm:min-w-7'
aria-label={removeLabel}
onClick={onClose}
disabled={isPending}
>
Expand Down
79 changes: 79 additions & 0 deletions src/features/filters/components/filter-overlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use client';

import { createContext, useCallback, useContext, useState } from 'react';

import { cn } from '@/lib/utils';
import { PopoverContent as BasePopoverContent } from '@/components/ui/popover';
import { DropdownMenuContent as BaseDropdownMenuContent } from '@/components/ui/dropdown-menu';

export { Popover, PopoverTrigger } from '@/components/ui/popover';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuCheckboxItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';

const FilterOverlayContext = createContext<HTMLElement | null>(null);

// Keep option lists inside the drawer's focus and scrolling boundary.
export function FilterOverlayProvider({ children }: React.PropsWithChildren) {
const [container, setContainer] = useState<HTMLElement | null>(null);
const setRef = useCallback((node: HTMLDivElement | null) => {
setContainer(
node?.closest<HTMLElement>('[data-slot="sheet-content"]') ?? null,
);
}, []);

return (
<FilterOverlayContext.Provider value={container}>
<div ref={setRef} className='flex min-w-0 flex-col gap-4'>
{children}
</div>
</FilterOverlayContext.Provider>
);
}

export function PopoverContent({
className,
...props
}: React.ComponentProps<typeof BasePopoverContent>) {
const container = useContext(FilterOverlayContext);
return (
<BasePopoverContent
{...props}
portalContainer={container}
collisionPadding={12}
sticky='always'
className={cn(
className,
'max-h-(--radix-popover-content-available-height) max-w-[min(calc(100vw-1.5rem),var(--radix-popover-content-available-width))] overflow-y-auto overscroll-contain',
'[&_[data-slot=command]]:h-auto [&_[data-slot=command]]:max-h-[inherit] [&_[data-slot=command]]:min-h-0',
'[&_[data-slot=command-input-wrapper]]:shrink-0 [&_[data-slot=command-input]]:text-base sm:[&_[data-slot=command-input]]:text-sm',
'[&_[data-slot=command-list]]:min-h-0 [&_[data-slot=command-list]]:touch-pan-y [&_[data-slot=command-list]]:overscroll-contain',
)}
/>
);
}

export function DropdownMenuContent({
className,
...props
}: React.ComponentProps<typeof BaseDropdownMenuContent>) {
const container = useContext(FilterOverlayContext);
return (
<BaseDropdownMenuContent
{...props}
portalContainer={container}
collisionPadding={12}
sticky='always'
className={cn(
className,
'max-w-[min(calc(100vw-1.5rem),var(--radix-dropdown-menu-content-available-width))] touch-pan-y overscroll-contain [&_[role^=menuitem]]:min-h-11 sm:[&_[role^=menuitem]]:min-h-8',
)}
/>
);
}
Loading
Loading