feat(discovery): move mobile filters into accessible drawer
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# Discovery mobile filters
|
||||
|
||||
The approved A3 refinement calls for a mobile filter drawer while retaining the desktop search rail. This package starts from locale-sort checkpoint `b29c78e86d256f0c4e29f104bcd0c45572116635` on isolated branch `codex/a3-discovery-mobile-filters`.
|
||||
|
||||
At widths up to 1000 px, the existing municipality, source disclosure and saved-search controls open from a named Filters button. Wider layouts retain the permanent rail. Search drafts and saved-search definitions remain in their existing state owner. The municipality control and drawer submit button target the original search form. Search and Jump to results close the drawer and move focus to results. Escape/Close return focus to the trigger. Crossing to the desktop rail closes the modal and focuses the query. Failed saved-search writes remain visible inside the drawer with their draft.
|
||||
|
||||
The initial mobile regression failed three cases against the previous page. The final 28 discovery cases passed, including EN/NB draft/submit/focus, breakpoint transitions, saved-write failure and existing permission, ordering and tracker behaviour. Type-check and scoped lint passed. Raw logs are in `evidence/discovery-mobile-filters/`.
|
||||
|
||||
This package uses the current NAV search contract. New geography, taxonomy, map, providers and data persistence retain their roadmap dependencies. Exported browser, visual and accessibility validation is pending integration. Native-device and final owner acceptance remain open.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
|
||||
> job-tracker-ui@0.1.0 test
|
||||
> jest --runInBand job-discovery.test.tsx
|
||||
|
||||
Test Suites: 1 passed, 1 total
|
||||
Tests: 28 passed, 28 total
|
||||
Snapshots: 0 total
|
||||
Time: 9.563 s, estimated 10 s
|
||||
Ran all test suites matching job-discovery.test.tsx.
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
> job-tracker-ui@0.1.0 typecheck
|
||||
> tsc --noEmit --incremental false
|
||||
|
||||
@@ -11,6 +11,8 @@ import { translations } from './i18n/translations';
|
||||
|
||||
jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn(), patch: jest.fn(), delete: jest.fn() } }));
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
let mockNarrow = false;
|
||||
jest.mock("@mui/material/useMediaQuery", () => ({ __esModule: true, default: () => mockNarrow }));
|
||||
|
||||
function renderPage() {
|
||||
return render(<I18nProvider><ConfirmProvider><PromptProvider><MemoryRouter><JobDiscoveryPage /></MemoryRouter></PromptProvider></ConfirmProvider></I18nProvider>);
|
||||
@@ -20,9 +22,83 @@ function searchResponse(data: any[]) {
|
||||
mockedApi.get.mockImplementation((url) => Promise.resolve({ data: String(url).endsWith("saved-searches") ? [] : data } as any));
|
||||
}
|
||||
|
||||
beforeEach(() => { jest.resetAllMocks(); window.localStorage.clear(); searchResponse([]); });
|
||||
beforeEach(() => { jest.resetAllMocks(); window.localStorage.clear(); mockNarrow = false; searchResponse([]); });
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
test.each(["en", "nb"] as const)("%s mobile filters retain drafts, restore focus and submit the existing search", async (language) => {
|
||||
mockNarrow = true;
|
||||
localStorage.setItem("uiLanguage", language);
|
||||
const copy = translations[language];
|
||||
renderPage();
|
||||
const form = screen.getByRole("form", { name: copy.jobDiscoverySearchNav });
|
||||
fireEvent.change(screen.getByLabelText(copy.jobDiscoveryRoleCompany), { target: { value: " nurse " } });
|
||||
const trigger = screen.getByRole("button", { name: copy.jobDiscoveryFilters });
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByLabelText(copy.jobDiscoveryMunicipality)).not.toBeInTheDocument();
|
||||
act(() => { trigger.focus(); });
|
||||
fireEvent.click(trigger);
|
||||
let drawer = await screen.findByRole("dialog", { name: copy.jobDiscoveryFilters });
|
||||
await waitFor(() => expect(within(drawer).getByRole("button", { name: copy.close })).toHaveFocus());
|
||||
const municipality = within(drawer).getByLabelText(copy.jobDiscoveryMunicipality) as HTMLInputElement;
|
||||
expect(municipality.form).toBe(form);
|
||||
fireEvent.change(municipality, { target: { value: " Oslo " } });
|
||||
fireEvent.change(within(drawer).getByLabelText(copy.jobDiscoverySavedName), { target: { value: "Care roles" } });
|
||||
fireEvent.keyDown(drawer, { key: "Escape", code: "Escape" });
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
fireEvent.click(trigger);
|
||||
drawer = await screen.findByRole("dialog", { name: copy.jobDiscoveryFilters });
|
||||
expect(within(drawer).getByLabelText(copy.jobDiscoveryMunicipality)).toHaveValue(" Oslo ");
|
||||
expect(within(drawer).getByLabelText(copy.jobDiscoverySavedName)).toHaveValue("Care roles");
|
||||
fireEvent.click(within(drawer).getByRole("button", { name: copy.jobDiscoverySearchNav }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
await screen.findByText(copy.jobDiscoveryNoResults);
|
||||
await waitFor(() => expect(screen.getByRole("region", { name: copy.jobDiscoveryResults })).toHaveFocus());
|
||||
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(["/job-discovery/saved-searches", "/job-discovery/search"]);
|
||||
expect(mockedApi.get).toHaveBeenLastCalledWith("/job-discovery/search", { params: { q: "nurse", location: "Oslo" } });
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("crossing the filter breakpoint closes the drawer and preserves drafts and a reachable focus target", async () => {
|
||||
mockNarrow = true;
|
||||
const view = renderPage();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Search filters" }));
|
||||
const drawer = await screen.findByRole("dialog", { name: "Search filters" });
|
||||
fireEvent.change(within(drawer).getByLabelText("Municipality"), { target: { value: "Bergen" } });
|
||||
fireEvent.change(within(drawer).getByLabelText("Search name"), { target: { value: "Local care" } });
|
||||
mockNarrow = false;
|
||||
view.rerender(<I18nProvider><ConfirmProvider><PromptProvider><MemoryRouter><JobDiscoveryPage /></MemoryRouter></PromptProvider></ConfirmProvider></I18nProvider>);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Role or company")).toHaveFocus();
|
||||
const filters = screen.getByRole("complementary", { name: "Search filters" });
|
||||
expect(within(filters).getByLabelText("Municipality")).toHaveValue("Bergen");
|
||||
expect(within(filters).getByLabelText("Search name")).toHaveValue("Local care");
|
||||
mockNarrow = true;
|
||||
view.rerender(<I18nProvider><ConfirmProvider><PromptProvider><MemoryRouter><JobDiscoveryPage /></MemoryRouter></PromptProvider></ConfirmProvider></I18nProvider>);
|
||||
const trigger = screen.getByRole("button", { name: "Search filters" });
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(trigger);
|
||||
const reopened = await screen.findByRole("dialog", { name: "Search filters" });
|
||||
expect(within(reopened).getByLabelText("Municipality")).toHaveValue("Bergen");
|
||||
expect(within(reopened).getByLabelText("Search name")).toHaveValue("Local care");
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a failed mobile saved-search write remains visible inside the drawer with its draft", async () => {
|
||||
mockNarrow = true;
|
||||
mockedApi.post.mockRejectedValueOnce(new Error("synthetic save failure"));
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Search filters" }));
|
||||
const drawer = await screen.findByRole("dialog", { name: "Search filters" });
|
||||
await within(drawer).findByText("No saved searches yet.");
|
||||
fireEvent.change(within(drawer).getByLabelText("Search name"), { target: { value: "Care roles" } });
|
||||
fireEvent.click(within(drawer).getByRole("button", { name: "Save search" }));
|
||||
expect(await within(drawer).findByText("The search could not be saved.")).toBeVisible();
|
||||
expect(within(drawer).getByLabelText("Search name")).toHaveValue("Care roles");
|
||||
expect(within(drawer).getByRole("button", { name: "Save search" })).toBeEnabled();
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['en', 'Search filters', 'Jump to results', 'Return to search', 'Sources and coverage'],
|
||||
['nb', 'Søkefiltre', 'Gå til resultater', 'Tilbake til søk', 'Kilder og dekning'],
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FormEvent, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Box, Button, Card, CardActions, CardContent, Chip, CircularProgress, FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, Card, CardActions, CardContent, Chip, CircularProgress, Drawer, FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import BookmarkAddOutlinedIcon from "@mui/icons-material/BookmarkAddOutlined";
|
||||
@@ -37,6 +39,9 @@ type SortOrder = "updated" | "deadline" | "title";
|
||||
export default function JobDiscoveryPage() {
|
||||
const navigate = useNavigate();
|
||||
const searchFormId = useId();
|
||||
const filtersId = useId();
|
||||
const isNarrow = useMediaQuery("(max-width:1000px)");
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const { language, t } = useI18n();
|
||||
const { confirmAction } = useDialogActions();
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -58,9 +63,29 @@ export default function JobDiscoveryPage() {
|
||||
const pending = useRef(false);
|
||||
const resultsRef = useRef<HTMLDivElement>(null);
|
||||
const queryRef = useRef<HTMLInputElement>(null);
|
||||
const filtersTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const focusResultsAfterDrawer = useRef(false);
|
||||
const drawerActive = useRef(false);
|
||||
const removedControlRef = useRef<Element | null>(null);
|
||||
const busy = loading || savingSearch || updatingSavedSearchId !== null || mutating;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNarrow) {
|
||||
const needsFocus = filtersOpen || drawerActive.current;
|
||||
focusResultsAfterDrawer.current = false;
|
||||
drawerActive.current = false;
|
||||
if (filtersOpen) setFiltersOpen(false);
|
||||
if (needsFocus) queryRef.current?.focus();
|
||||
}
|
||||
}, [isNarrow, filtersOpen]);
|
||||
|
||||
const showResults = () => {
|
||||
if (isNarrow && filtersOpen) {
|
||||
focusResultsAfterDrawer.current = true;
|
||||
setFiltersOpen(false);
|
||||
} else resultsRef.current?.focus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const control = removedControlRef.current;
|
||||
removedControlRef.current = null;
|
||||
@@ -99,6 +124,7 @@ export default function JobDiscoveryPage() {
|
||||
|
||||
const search = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (isNarrow && filtersOpen) showResults();
|
||||
void runSearch(query.trim(), location.trim());
|
||||
};
|
||||
|
||||
@@ -117,6 +143,7 @@ export default function JobDiscoveryPage() {
|
||||
|
||||
const runSavedSearch = async (saved: SavedSearch, applyFilters = true) => {
|
||||
if (pending.current) return;
|
||||
if (isNarrow && filtersOpen) showResults();
|
||||
pending.current = true;
|
||||
setLoading(true); setError(null); setJobs([]); setHasSearched(true); setActiveSavedSearchId(saved.id);
|
||||
if (applyFilters) { setQuery(saved.query); setLocation(saved.location); }
|
||||
@@ -189,10 +216,22 @@ export default function JobDiscoveryPage() {
|
||||
<TextField inputRef={queryRef} label={t("jobDiscoveryRoleCompany")} value={query} disabled={busy} onChange={(event) => setQuery(event.target.value)} />
|
||||
<Button type="submit" variant="contained" startIcon={loading ? <CircularProgress aria-hidden="true" size={18} color="inherit" /> : <SearchIcon />} disabled={busy}>{t("jobDiscoverySearchNav")}</Button>
|
||||
</Box>
|
||||
{isNarrow ? <Button ref={filtersTriggerRef} startIcon={<FilterListIcon />} aria-haspopup="dialog" aria-expanded={filtersOpen} aria-controls={filtersId} onClick={() => setFiltersOpen(true)} sx={{ alignSelf: "flex-start", minHeight: 44 }}>{t("jobDiscoveryFilters")}</Button> : null}
|
||||
<Box data-discovery-layout sx={{ display: "grid", gap: 3, minWidth: 0, alignItems: "start", "@media (min-width:1001px)": { gridTemplateColumns: "minmax(16rem, 20rem) minmax(0, 1fr)" } }}>
|
||||
<Drawer variant={isNarrow ? "temporary" : "permanent"} open={!isNarrow || filtersOpen} onClose={() => setFiltersOpen(false)} ModalProps={{ disableRestoreFocus: true }}
|
||||
slotProps={{ paper: { id: filtersId, role: isNarrow ? "dialog" : undefined, "aria-modal": isNarrow ? true : undefined, "aria-label": isNarrow ? t("jobDiscoveryFilters") : undefined }, transition: { onEnter: () => { drawerActive.current = true; }, onExited: () => {
|
||||
drawerActive.current = false;
|
||||
if (focusResultsAfterDrawer.current) {
|
||||
focusResultsAfterDrawer.current = false;
|
||||
resultsRef.current?.focus();
|
||||
} else filtersTriggerRef.current?.focus();
|
||||
} } }}
|
||||
sx={{ minWidth: 0, "& .MuiDrawer-paper": isNarrow ? { width: "min(26rem, 100vw)", maxWidth: "100vw", p: 2, boxSizing: "border-box" } : { position: "static", border: 0, bgcolor: "transparent", overflow: "visible" } }}>
|
||||
<Box component="aside" aria-label={t("jobDiscoveryFilters")} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
|
||||
<PageHeader level="h2" title={t("jobDiscoveryFilters")} actions={<Button onClick={() => resultsRef.current?.focus()}>{t("jobDiscoveryJumpResults")}</Button>} />
|
||||
<PageHeader level="h2" title={t("jobDiscoveryFilters")} actions={<Stack direction="row" spacing={1}>{isNarrow ? <Button autoFocus onClick={() => setFiltersOpen(false)}>{t("close")}</Button> : null}<Button onClick={showResults}>{t("jobDiscoveryJumpResults")}</Button></Stack>} />
|
||||
{isNarrow && error && error !== "jobDiscoveryUnavailable" ? <ViewStateNotice compact title={t(error)} error={{ kind: "error", message: t(error), retryable: false }} /> : null}
|
||||
<TextField label={t("jobDiscoveryMunicipality")} value={location} disabled={busy} onChange={(event) => setLocation(event.target.value)} slotProps={{ htmlInput: { form: searchFormId } }} />
|
||||
{isNarrow ? <Button type="submit" form={searchFormId} variant="contained" disabled={busy} startIcon={<SearchIcon />}>{t("jobDiscoverySearchNav")}</Button> : null}
|
||||
<Box component="details">
|
||||
<Typography component="summary" variant="body2" sx={{ cursor: "pointer", py: 0.5, "@media (pointer: coarse)": { minHeight: 44 } }}>{t("jobDiscoverySourceCoverage")}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{t("jobDiscoverySourceHelp")}</Typography>
|
||||
@@ -234,6 +273,7 @@ export default function JobDiscoveryPage() {
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Drawer>
|
||||
<Box role="region" aria-label={t("jobDiscoveryResults")} tabIndex={-1} ref={resultsRef} aria-busy={loading} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
|
||||
<PageHeader level="h2" title={t("jobDiscoveryResults")} actions={<Button disabled={busy} onClick={() => queryRef.current?.focus()}>{t("jobDiscoveryReturnFilters")}</Button>} />
|
||||
<ViewStateNotice compact loading={loading} title={t(error ?? "jobDiscoveryUnavailable")}
|
||||
|
||||
Reference in New Issue
Block a user