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 @@ -29,6 +29,7 @@ vi.mock('@/lib/env/client', () => ({
}));

import { FiltersAsideClient } from './filters-aside.client';
import { FiltersDrawerClient } from './filters-drawer.client';

const configs: FilterConfigSchema[] = [
{
Expand Down Expand Up @@ -62,6 +63,58 @@ afterEach(() => {
});

describe('main filter panel', () => {
it('renders the same controls in the mobile drawer as in the desktop panel, including the last group', async () => {
const user = userEvent.setup();
const desktop = render(<FiltersAsideClient configs={configs} />);
const labels = screen
.getAllByRole('button')
.map((button) => button.textContent);
desktop.unmount();
render(<FiltersDrawerClient configs={configs} />);
await user.click(screen.getByRole('button', { name: 'Filters' }));
const panel = screen.getByRole('region', { name: 'All job filters' });
expect(
within(panel)
.getAllByRole('button')
.map((button) => button.textContent),
).toEqual(labels);
expect(
screen.queryByRole('textbox', { name: 'Find a filter' }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /more filters/i }),
).not.toBeInTheDocument();
await user.click(within(panel).getByRole('button', { name: 'Has Token' }));
expect(setParam).toHaveBeenCalledWith('token', 'true');
});

it('exposes sorting in the shared panel and sends changes to the server query with pagination reset', async () => {
const user = userEvent.setup();
render(
<FiltersAsideClient
configs={[
{
...configs[0],
kind: 'SORT',
label: 'Order By',
paramKey: 'orderBy',
options: [
{ label: 'Publication Date', value: 'publicationDate' },
{ label: 'Salary', value: 'salary' },
],
},
]}
/>,
);
await user.click(
screen.getByRole('button', { name: 'Sort by Publication Date' }),
);
expect(setParam).not.toHaveBeenCalled();
await user.click(screen.getByRole('menuitemradio', { name: 'Salary' }));
expect(setParam).toHaveBeenCalledWith('orderBy', 'salary');
expect(setParam).toHaveBeenCalledWith('page', null);
});

it('opens continent search without applying Africa or navigating before a choice', async () => {
Element.prototype.scrollIntoView = vi.fn();
const user = userEvent.setup();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '@/features/filters/hooks';
import { ActiveFilters } from '@/features/filters/components/active-filters';
import { SuggestedFilters } from '@/features/filters/components/suggested-filters';
import { SortFilters } from '@/features/filters/components/sort-filters';
import { FilterOverlayProvider } from '@/features/filters/components/filter-overlay';

interface Props {
Expand Down Expand Up @@ -38,6 +39,7 @@ export const FiltersAsideClient = ({
configs={filteredConfigs}
pillarContext={pillarMode ? null : pillarContext}
/>
<SortFilters configs={filteredConfigs} />
</FilterOverlayProvider>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,27 @@ export const FiltersDrawerClient = ({
</SheetTrigger>
<SheetContent
side='left'
className='h-dvh w-full max-w-sm gap-0'
className='h-dvh w-full max-w-md gap-0 overflow-hidden [&>button:last-child]:top-6'
// Don't auto-focus the first chip — it pops its tooltip on open
onOpenAutoFocus={(event) => event.preventDefault()}
>
<SheetHeader className='border-b border-border/50'>
<SheetTitle>Filters</SheetTitle>
<SheetHeader className='shrink-0 border-b border-border/50'>
<div className='flex min-h-11 items-center justify-between gap-3 pr-6'>
<SheetTitle>Filters</SheetTitle>
<SheetClose asChild>
<Button className='min-h-11'>Show results</Button>
</SheetClose>
</div>
<SheetDescription className='sr-only'>
Refine the jobs shown in the results list.
</SheetDescription>
</SheetHeader>
<div className='min-h-0 flex-1 overflow-y-auto overscroll-contain p-4'>
<div
role='region'
aria-label='All job filters'
tabIndex={0}
className='min-h-0 flex-1 touch-pan-y overflow-y-scroll overscroll-contain p-4 pb-[max(1rem,env(safe-area-inset-bottom))] [scrollbar-gutter:stable]'
>
<div className='flex flex-col gap-4'>
<FiltersAsideClient
configs={configs}
Expand All @@ -82,11 +92,6 @@ export const FiltersDrawerClient = ({
/>
</div>
</div>
<div className='shrink-0 border-t p-4 pb-[max(1rem,env(safe-area-inset-bottom))]'>
<SheetClose asChild>
<Button className='min-h-11 w-full'>Show results</Button>
</SheetClose>
</div>
</SheetContent>
</Sheet>
);
Expand Down
87 changes: 87 additions & 0 deletions src/features/filters/components/sort-filters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'use client';

import { useTransition } from 'react';
import { ChevronDownIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { FILTER_KIND } from '@/features/filters/constants';
import type {
FilterConfigSchema,
SortFilterConfigSchema,
} from '@/features/filters/schemas';
import { useFilterQueryState } from '@/features/filters/hooks';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from './filter-overlay';

export function SortFilters({ configs }: { configs: FilterConfigSchema[] }) {
const sorts = configs
.filter((config) => config.kind === FILTER_KIND.SORT)
.sort(
(a, b) =>
Number(b.paramKey === 'orderBy') - Number(a.paramKey === 'orderBy'),
);
if (!sorts.length) return null;
return (
<section aria-label='Sort results' className='space-y-2'>
<h3 className='text-[11px] font-medium tracking-wide text-muted-foreground'>
Sort results
</h3>
{sorts.map((config) => (
<SortControl key={config.paramKey} config={config} />
))}
</section>
);
}

function SortControl({ config }: { config: SortFilterConfigSchema }) {
const [value, setValue] = useFilterQueryState(config.paramKey);
const [pending, startTransition] = useTransition();
const isDirection = config.paramKey === 'order';
const label = isDirection ? 'Order' : 'Sort by';
const selected = value ?? (isDirection ? 'desc' : 'publicationDate');
const options = config.options.map((option) => ({
...option,
label: isDirection
? option.value === 'asc'
? 'Ascending'
: 'Descending'
: option.label,
}));
return (
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={pending}>
<Button
variant='outline'
aria-label={`${label} ${options.find((option) => option.value === selected)?.label ?? ''}`}
className='min-h-11 w-full justify-between gap-2 px-3 text-left text-sm whitespace-normal'
>
<span className='text-muted-foreground'>{label}</span>
<span className='ml-auto'>
{options.find((option) => option.value === selected)?.label}
</span>
<ChevronDownIcon className='size-4 shrink-0' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuRadioGroup
value={selected}
onValueChange={(next) =>
startTransition(() => {
setValue(next);
})
}
>
{options.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
5 changes: 5 additions & 0 deletions src/features/filters/filter-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ export const FILTER_GROUPS = [
'maxMonthlyRevenue',
]),
},
{
id: 'sort',
label: 'Sort results',
paramKeys: new Set(['order', 'orderBy']),
},
] as const;

export type FilterGroupId = (typeof FILTER_GROUPS)[number]['id'] | 'other';
Expand Down
Loading