Skip to content

Commit 360d524

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
fix(spa): place the bulk-actions menu left of the search input (#512)
The Actions menu lived in the FilterBar `trailing` slot, so it rendered to the right of the search input. Django's changelist leads the toolbar with the actions selector, and it should sit before search once a row is selected. Add a `leading` slot to FilterBar (rendered before the search input) and move the Actions menu there; the column "Customize" button stays trailing. The menu still appears only when the user can run actions and at least one row is selected. Tests: add a FilterBar case asserting `leading` precedes the search input in the DOM (101 passed). Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3fe8bf7 commit 360d524

3 files changed

Lines changed: 81 additions & 51 deletions

File tree

frontend/apps/web/src/pages/ListPage.tsx

Lines changed: 53 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -476,58 +476,61 @@ export function ListPage() {
476476
active={activeFilters}
477477
onFilterChange={setFilter}
478478
onClearAll={() => patchParams((next) => filters.forEach((f) => next.delete(f.name)))}
479-
trailing={
480-
<>
481-
{canRunActions && selected.size > 0 && (
482-
<div className="relative">
483-
<button
484-
type="button"
485-
onClick={() => setActionsOpen((o) => !o)}
486-
aria-haspopup="menu"
487-
aria-expanded={actionsOpen}
488-
disabled={runningAction}
489-
className="shrink-0 rounded-md border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-100 disabled:opacity-50"
479+
// Bulk-actions menu sits to the LEFT of the search input, and
480+
// only once at least one row is selected (Django changelist
481+
// parity — the actions selector leads the toolbar).
482+
leading={
483+
canRunActions && selected.size > 0 ? (
484+
<div className="relative">
485+
<button
486+
type="button"
487+
onClick={() => setActionsOpen((o) => !o)}
488+
aria-haspopup="menu"
489+
aria-expanded={actionsOpen}
490+
disabled={runningAction}
491+
className="shrink-0 rounded-md border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-100 disabled:opacity-50"
492+
>
493+
Actions · {selected.size}
494+
</button>
495+
{actionsOpen && (
496+
<div
497+
role="menu"
498+
className="absolute left-0 z-20 mt-1 min-w-48 rounded border border-gray-200 bg-white py-1 shadow-lg"
490499
>
491-
Actions · {selected.size}
492-
</button>
493-
{actionsOpen && (
494-
<div
495-
role="menu"
496-
className="absolute left-0 z-20 mt-1 min-w-48 rounded border border-gray-200 bg-white py-1 shadow-lg"
497-
>
498-
{actions.map((a) => (
499-
<button
500-
key={a.name}
501-
type="button"
502-
role="menuitem"
503-
onClick={() => requestAction(a)}
504-
className="block w-full px-3 py-2 text-left text-sm hover:bg-gray-100"
505-
title={a.description}
506-
>
507-
{a.label}
508-
</button>
509-
))}
510-
</div>
511-
)}
512-
</div>
513-
)}
514-
<button
515-
type="button"
516-
onClick={() => setColsOpen(true)}
517-
aria-haspopup="dialog"
518-
aria-label="Customize columns"
519-
title="Customize columns"
520-
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-100"
521-
>
522-
<Settings2 className="h-4 w-4" aria-hidden />
523-
Customize
524-
{hiddenCols.size > 0 && (
525-
<span className="ml-0.5 rounded-full bg-gray-500 px-1.5 py-0.5 text-xs text-white">
526-
{hiddenCols.size} hidden
527-
</span>
500+
{actions.map((a) => (
501+
<button
502+
key={a.name}
503+
type="button"
504+
role="menuitem"
505+
onClick={() => requestAction(a)}
506+
className="block w-full px-3 py-2 text-left text-sm hover:bg-gray-100"
507+
title={a.description}
508+
>
509+
{a.label}
510+
</button>
511+
))}
512+
</div>
528513
)}
529-
</button>
530-
</>
514+
</div>
515+
) : null
516+
}
517+
trailing={
518+
<button
519+
type="button"
520+
onClick={() => setColsOpen(true)}
521+
aria-haspopup="dialog"
522+
aria-label="Customize columns"
523+
title="Customize columns"
524+
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-100"
525+
>
526+
<Settings2 className="h-4 w-4" aria-hidden />
527+
Customize
528+
{hiddenCols.size > 0 && (
529+
<span className="ml-0.5 rounded-full bg-gray-500 px-1.5 py-0.5 text-xs text-white">
530+
{hiddenCols.size} hidden
531+
</span>
532+
)}
533+
</button>
531534
}
532535
/>
533536

frontend/packages/search/src/FilterBar.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,26 @@ describe('FilterBar', () => {
7878
fireEvent.change(screen.getByLabelText('Search'), { target: { value: 'abc' } });
7979
expect(onSearchChange).toHaveBeenCalledWith('abc');
8080
});
81+
82+
it('renders `leading` content before the search input (to its left)', () => {
83+
render(
84+
<FilterBar
85+
searchValue=""
86+
onSearchChange={() => {}}
87+
filters={[]}
88+
active={{}}
89+
onFilterChange={() => {}}
90+
onClearAll={() => {}}
91+
leading={<button type="button">Actions · 2 ▾</button>}
92+
trailing={<button type="button">Customize</button>}
93+
/>,
94+
);
95+
const actions = screen.getByRole('button', { name: /Actions/ });
96+
const search = screen.getByLabelText('Search');
97+
// `leading` precedes the search input in the DOM, so the actions
98+
// menu sits to its left in the toolbar row.
99+
expect(
100+
actions.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING,
101+
).toBeTruthy();
102+
});
81103
});

frontend/packages/search/src/FilterBar.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,11 @@ export interface FilterBarProps {
2828
/** Set/clear one filter — pass '' to clear it. */
2929
onFilterChange: (name: string, value: string) => void;
3030
onClearAll: () => void;
31+
/** Controls rendered to the **left** of the search input (e.g. the
32+
* bulk-actions menu, which sits before search when rows are selected). */
33+
leading?: ReactNode;
3134
/** Extra toolbar controls rendered to the right of the search input
32-
* (e.g. the column customizer + bulk-actions menu). */
35+
* (e.g. the column customizer). */
3336
trailing?: ReactNode;
3437
}
3538

@@ -54,12 +57,14 @@ export function FilterBar({
5457
active,
5558
onFilterChange,
5659
onClearAll,
60+
leading,
5761
trailing,
5862
}: FilterBarProps) {
5963
const anyActive = filters.some((f) => active[f.name]);
6064
return (
6165
<div className="space-y-2">
6266
<div className="flex flex-wrap items-center gap-2">
67+
{leading}
6368
{showSearch && (
6469
<form
6570
className="w-full max-w-sm"

0 commit comments

Comments
 (0)