diff --git a/src/app/api/jobs/cards/[id]/route.test.ts b/src/app/api/jobs/cards/[id]/route.test.ts new file mode 100644 index 0000000..562f9b7 --- /dev/null +++ b/src/app/api/jobs/cards/[id]/route.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { jobListItemSchema } from '@/features/jobs/schemas'; +import { makeJobDto } from '@/features/jobs/test/job-fixtures'; +import { GET } from './route'; + +vi.mock('@/lib/env/client', () => ({ + clientEnv: { MW_URL: 'https://middleware.test' }, +})); + +afterEach(() => vi.unstubAllGlobals()); +const request = (id: string) => + GET(new Request('https://jobstash.xyz/api/jobs/cards/job-1'), { + params: Promise.resolve({ id }), + }); + +describe('GET /api/jobs/cards/[id]', () => { + it('reuses the job details endpoint and returns a valid card with the correct organization link', async () => { + const fetch = vi.fn().mockResolvedValue(Response.json(makeJobDto())); + vi.stubGlobal('fetch', fetch); + const response = await request('job-1'); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + const body = jobListItemSchema.parse(await response.json()); + expect(body.id).toBe('job-1'); + expect(body.organization?.href).toBe('/o-acme~org-acme'); + expect(fetch).toHaveBeenCalledWith( + 'https://middleware.test/jobs/details/job-1', + { cache: 'no-store' }, + ); + }); + + it.each(['../admin', 'job?token=value', '', 'a'.repeat(101)])( + 'rejects invalid identifiers before calling middleware: %s', + async (id) => { + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + const response = await request(id); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: 'Job not found' }); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + it('preserves not found responses without forwarding upstream response bodies', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + Response.json({ error: 'Internal detail' }, { status: 404 }), + ), + ); + const response = await request('job-1'); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: 'Job not found' }); + }); + + it.each([ + 'upstream failure', + 'wrong job', + 'malformed job', + 'network failure', + ])('returns a safe response for %s', async (failure) => { + const fetch = vi.fn(); + if (failure === 'network failure') + fetch.mockRejectedValue(new Error('Database credential / private host')); + else + fetch.mockResolvedValue( + failure === 'upstream failure' + ? Response.json( + { error: 'Database credential / private host' }, + { status: 500 }, + ) + : Response.json( + failure === 'wrong job' + ? makeJobDto('another-job') + : { shortUUID: 'job-1' }, + ), + ); + vi.stubGlobal('fetch', fetch); + const response = await request('job-1'); + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ error: 'Job unavailable' }); + }); +}); diff --git a/src/app/api/jobs/cards/[id]/route.ts b/src/app/api/jobs/cards/[id]/route.ts new file mode 100644 index 0000000..58cf6e1 --- /dev/null +++ b/src/app/api/jobs/cards/[id]/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { clientEnv } from '@/lib/env/client'; +import { jobListItemDto } from '@/features/jobs/server/dtos/job-list-item.dto'; +import { dtoToJobListItem } from '@/features/jobs/server/dtos/dto-to-job-list-item'; + +// Reuse the public job detail lookup; stacks need only the existing card fields. +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + if (!/^[a-zA-Z0-9_-]{1,100}$/.test(id)) + return NextResponse.json({ error: 'Job not found' }, { status: 404 }); + + try { + const response = await fetch( + `${clientEnv.MW_URL}/jobs/details/${encodeURIComponent(id)}`, + { cache: 'no-store' }, + ); + if (response.status === 404) + return NextResponse.json({ error: 'Job not found' }, { status: 404 }); + if (!response.ok) throw new Error('Job lookup failed'); + const job = jobListItemDto.parse(await response.json()); + if (job.shortUUID !== id) throw new Error('Job lookup mismatch'); + return NextResponse.json(dtoToJobListItem(job), { + headers: { 'Cache-Control': 'no-store' }, + }); + } catch { + return NextResponse.json({ error: 'Job unavailable' }, { status: 502 }); + } +} diff --git a/src/features/jobs/components/job-list/job-list-item/index.ts b/src/features/jobs/components/job-list/job-list-item/index.ts index 943564a..fac1333 100644 --- a/src/features/jobs/components/job-list/job-list-item/index.ts +++ b/src/features/jobs/components/job-list/job-list-item/index.ts @@ -1,4 +1,4 @@ -export { JobListItem } from './job-list-item'; +export { JobListItem, JobListItemFrame } from './job-list-item'; export { JobListItemBadge } from './job-list-item-badge'; export { JobListItemTechTags } from './job-list-item-tech-tags'; export { JobListItemInfoTags } from './job-list-item-info-tags'; diff --git a/src/features/jobs/components/job-list/job-list-item/job-list-item.tsx b/src/features/jobs/components/job-list/job-list-item/job-list-item.tsx index 7639c55..f98812b 100644 --- a/src/features/jobs/components/job-list/job-list-item/job-list-item.tsx +++ b/src/features/jobs/components/job-list/job-list-item/job-list-item.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { ReactNode } from 'react'; +import { Fragment, type ReactNode } from 'react'; import { ArrowRightIcon, ExternalLinkIcon } from 'lucide-react'; @@ -22,9 +22,20 @@ import { AvailabilityPills } from '@/features/jobs/components/availability-pills interface JobListItemProps { job: JobListItemSchema; footer?: ReactNode; + contentOnly?: boolean; } -export const JobListItem = ({ job, footer }: JobListItemProps) => { +export const JobListItemFrame = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+); + +export const JobListItem = ({ + job, + footer, + contentOnly = false, +}: JobListItemProps) => { const { title, href, organization, infoTags, tags, badge, timestampText } = job; @@ -36,15 +47,9 @@ export const JobListItem = ({ job, footer }: JobListItemProps) => { }); }; + const Frame = contentOnly ? Fragment : JobListItemFrame; return ( -
+
{
{footer} -
+ ); }; diff --git a/src/features/jobs/components/job-list/job-list.tsx b/src/features/jobs/components/job-list/job-list.tsx index d5bb6d3..f753240 100644 --- a/src/features/jobs/components/job-list/job-list.tsx +++ b/src/features/jobs/components/job-list/job-list.tsx @@ -103,6 +103,8 @@ export const JobList = async ({ ) : ( diff --git a/src/features/jobs/components/job-list/job-stack.test.tsx b/src/features/jobs/components/job-list/job-stack.test.tsx index cc61adb..0beda16 100644 --- a/src/features/jobs/components/job-list/job-stack.test.tsx +++ b/src/features/jobs/components/job-list/job-stack.test.tsx @@ -1,22 +1,30 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { JobListItemSchema } from '@/features/jobs/schemas'; +import { jobTitles, makeJob } from '../../test/job-fixtures'; import { JobStack } from './job-stack'; import { getPageHref } from './job-list-pagination'; vi.mock('./job-list-item', () => ({ - JobListItem: ({ - job, - footer, - }: { - job: JobListItemSchema; - footer: React.ReactNode; - }) => ( -
+ JobListItemFrame: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + JobListItem: ({ job }: { job: JobListItemSchema }) => ( + <>

{job.title}

- {footer} -
+ {/* Plain anchor keeps this fixture independent of Next navigation. */} + {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} + View details + ), })); vi.mock('@/components/link-with-loader', () => ({ @@ -24,52 +32,324 @@ vi.mock('@/components/link-with-loader', () => ({ {children} ), })); -vi.mock('@/lib/analytics', () => ({ GA_EVENT: {}, trackEvent: vi.fn() })); -afterEach(cleanup); -const jobs = Array.from( - { length: 5 }, - (_, index) => - ({ - id: String(index), - title: `Engineer ${index}`, - organization: { name: 'Acme', href: '/o-acme~org-acme' }, - }) as JobListItemSchema, -); + +const clients: QueryClient[] = []; +const renderStack = ( + jobs: JobListItemSchema[], + initialJobs = jobs, + importRunId = 'run-1', +) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + clients.push(client); + const props = { jobs: initialJobs, jobTitles: jobTitles(jobs), importRunId }; + const view = render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + return { ...view, props, client }; +}; +const jobs = Array.from({ length: 5 }, (_, index) => makeJob(String(index))); +const swipe = (target: HTMLElement, dx: number, dy = 0) => { + fireEvent.touchStart(target, { touches: [{ clientX: 200, clientY: 200 }] }); + fireEvent.touchEnd(target, { + changedTouches: [{ clientX: 200 + dx, clientY: 200 + dy }], + }); +}; + +afterEach(() => { + cleanup(); + clients.splice(0).forEach((client) => client.clear()); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); describe('organization card stack', () => { - it('mounts one card, pages locally, and links to all organization jobs', () => { - const { container } = render(); - expect(container.querySelectorAll('article')).toHaveLength(1); + it('selects named jobs, mounts one card and links to the complete organization listing', () => { + const { container } = renderStack(jobs); + expect(screen.getAllByRole('tab')).toHaveLength(5); expect(screen.getByRole('button', { name: 'Previous job' })).toBeDisabled(); + fireEvent.click(screen.getByRole('tab', { name: 'Engineer 3' })); + expect(screen.getByRole('heading')).toHaveTextContent('Engineer 3'); + expect(screen.getByRole('status')).toHaveTextContent('4 of 5'); + expect(screen.getByRole('tab', { name: 'Engineer 3' })).toHaveAttribute( + 'aria-selected', + 'true', + ); fireEvent.click(screen.getByRole('button', { name: 'Next job' })); - expect(screen.getByRole('heading').textContent).toBe('Engineer 1'); - expect(screen.getByRole('status').textContent).toBe('2 of 5'); - fireEvent.click(screen.getByRole('button', { name: 'Show job 5 of 5' })); + expect(screen.getByRole('heading')).toHaveTextContent('Engineer 4'); expect(screen.getByRole('button', { name: 'Next job' })).toBeDisabled(); - expect(screen.getByRole('link').getAttribute('href')).toBe( - '/o-acme~org-acme', + expect( + screen.getByRole('link', { name: 'All jobs at Acme' }), + ).toHaveAttribute('href', '/o-acme~org-acme'); + expect(container.querySelectorAll('article')).toHaveLength(1); + }); + + it('preserves all 63 imported jobs and the selection when the title strip shrinks', () => { + const observers = new Set<() => void>(); + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(private callback: () => void) {} + observe() { + observers.add(this.callback); + } + disconnect() { + observers.delete(this.callback); + } + }, + ); + const batch = Array.from({ length: 63 }, (_, i) => makeJob(String(i))); + const { container } = renderStack(batch); + fireEvent.click(screen.getByRole('tab', { name: 'Engineer 62' })); + const strip = screen.getByRole('tablist'); + vi.spyOn(strip, 'getBoundingClientRect').mockReturnValue({ + left: 0, + right: 160, + width: 160, + } as DOMRect); + act(() => observers.forEach((callback) => callback())); + expect(screen.getAllByRole('tab')).toHaveLength(63); + expect(screen.getByRole('heading')).toHaveTextContent('Engineer 62'); + expect(screen.getByRole('status')).toHaveTextContent('63 of 63'); + expect(screen.getByRole('button', { name: 'Next job' })).toBeDisabled(); + expect(container.querySelectorAll('article')).toHaveLength(1); + expect(container.querySelectorAll('div[aria-hidden="true"]')).toHaveLength( + 3, ); }); - it('preserves the selected job after refresh, then falls back when it disappears', () => { - const view = render(); - fireEvent.click(screen.getByRole('button', { name: 'Next job' })); - view.rerender(); - expect(screen.getByRole('heading').textContent).toBe('Engineer 1'); - view.rerender(); - expect(screen.getByRole('heading').textContent).toBe('Engineer 4'); - view.rerender(); - expect(screen.getByRole('heading').textContent).toBe('Engineer 4'); + + it('uses location to distinguish duplicate titles before selection', () => { + const duplicates = ['Amsterdam', 'Berlin'].map((location, index) => ({ + ...makeJob(String(index)), + title: 'Staff Engineer', + location, + })); + renderStack(duplicates); + expect( + screen.getByRole('tab', { name: 'Staff Engineer · Amsterdam' }), + ).toHaveAttribute('aria-selected', 'true'); + fireEvent.click( + screen.getByRole('tab', { name: 'Staff Engineer · Berlin' }), + ); + expect( + screen.getByRole('tab', { name: 'Staff Engineer · Berlin' }), + ).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('status')).toHaveTextContent('2 of 2'); }); - it('omits pager and layers for a single job', () => { - const { container } = render(); + + it('supports keyboard navigation and keeps focus on the selected title', () => { + renderStack(jobs); + const first = screen.getByRole('tab', { name: 'Engineer 0' }); + first.focus(); + fireEvent.keyDown(first, { key: 'End' }); + expect(screen.getByRole('heading')).toHaveTextContent('Engineer 4'); + expect(screen.getByRole('tab', { name: 'Engineer 4' })).toHaveFocus(); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' }); + expect(screen.getByRole('heading')).toHaveTextContent('Engineer 3'); + expect(screen.getByRole('tab', { name: 'Engineer 3' })).toHaveFocus(); + fireEvent.keyDown(document.activeElement!, { key: 'Home' }); + expect(screen.getByRole('heading')).toHaveTextContent('Engineer 0'); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' }); + expect(screen.getByRole('heading')).toHaveTextContent('Engineer 1'); + expect( + screen.getAllByRole('tab').filter((tab) => tab.tabIndex === 0), + ).toEqual([screen.getByRole('tab', { name: 'Engineer 1' })]); + }); + + it('preserves selected identity across refresh and falls back when that job leaves the batch', () => { + const { rerender, props } = renderStack(jobs); + fireEvent.click(screen.getByRole('tab', { name: 'Engineer 1' })); + const reordered = [jobs[4], ...jobs.slice(0, 4)]; + rerender( + , + ); + expect(screen.getByRole('heading')).toHaveTextContent('Engineer 1'); + const nextBatch = [jobs[4], jobs[3]]; + rerender( + , + ); + expect(screen.getByRole('heading')).toHaveTextContent('Engineer 4'); + expect(screen.getByRole('status')).toHaveTextContent('1 of 2'); + }); + + it('omits paging and decorative layers for one job while keeping the organization link', () => { + const { container } = renderStack([jobs[0]]); expect(screen.queryByRole('navigation')).toBeNull(); + expect(screen.queryByRole('tablist')).toBeNull(); expect(container.querySelector('div[aria-hidden="true"]')).toBeNull(); - expect(screen.getByRole('link')).toBeTruthy(); + expect( + screen.getByRole('link', { name: 'All jobs at Acme' }), + ).toBeVisible(); }); - it('keeps pillar paths and filters across pages', () => { + + it('swipes cards without intercepting vertical scrolling, title scrolling or controls', () => { + renderStack(jobs); + swipe(screen.getByRole('heading'), -100); + expect(screen.getByRole('status')).toHaveTextContent('2 of 5'); + swipe(screen.getByRole('heading'), -100, 150); + swipe(screen.getByRole('link', { name: 'View details' }), -100); + swipe(screen.getByRole('button', { name: 'Next job' }), -100); + swipe(screen.getByRole('tablist'), -100); + expect(screen.getByRole('status')).toHaveTextContent('2 of 5'); + swipe(screen.getByRole('heading'), 100); + swipe(screen.getByRole('heading'), 100); + expect(screen.getByRole('status')).toHaveTextContent('1 of 5'); + const card = screen.getByRole('heading'); + fireEvent.touchStart(card, { touches: [{ clientX: 200, clientY: 200 }] }); + fireEvent.touchCancel(card); + fireEvent.touchEnd(card, { + changedTouches: [{ clientX: 0, clientY: 200 }], + }); + expect(screen.getByRole('status')).toHaveTextContent('1 of 5'); + fireEvent.touchStart(card, { touches: [{ clientX: 200, clientY: 200 }] }); + fireEvent.touchMove(card, { + touches: [ + { clientX: 180, clientY: 200 }, + { clientX: 150, clientY: 200 }, + ], + }); + fireEvent.touchEnd(card, { + changedTouches: [{ clientX: 0, clientY: 200 }], + }); + expect(screen.getByRole('status')).toHaveTextContent('1 of 5'); + }); + + it('keeps pillar paths and filters across feed pages', () => { expect( getPageHref(2, { workModes: 'remote', page: '1' }, '/lt-fully-remote'), ).toBe('/lt-fully-remote?page=2&workModes=remote'); expect(getPageHref(1, {}, '/o-acme~org-acme')).toBe('/o-acme~org-acme'); }); }); + +describe('loading additional job cards', () => { + it('loads a selected card once and reuses it when returning to the title', async () => { + const fetch = vi.fn().mockResolvedValue(Response.json(jobs[1])); + vi.stubGlobal('fetch', fetch); + renderStack(jobs, [jobs[0]]); + expect(fetch).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('tab', { name: 'Engineer 1' })); + await waitFor(() => + expect( + screen.getByRole('link', { name: 'View details' }), + ).toHaveAttribute('href', jobs[1].href), + ); + fireEvent.click(screen.getByRole('tab', { name: 'Engineer 0' })); + fireEvent.click(screen.getByRole('tab', { name: 'Engineer 1' })); + expect(screen.getByRole('link', { name: 'View details' })).toHaveAttribute( + 'href', + jobs[1].href, + ); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + '/api/jobs/cards/1', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('keeps keyboard focus on the title while its details load and after they arrive', async () => { + let finish!: (response: Response) => void; + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ), + ); + renderStack(jobs, [jobs[0]]); + const first = screen.getByRole('tab', { name: 'Engineer 0' }); + first.focus(); + fireEvent.keyDown(first, { key: 'End' }); + expect(screen.getByText('Loading job details…')).toBeVisible(); + expect(screen.getByRole('tab', { name: 'Engineer 4' })).toHaveFocus(); + await act(async () => finish(Response.json(jobs[4]))); + await waitFor(() => + expect( + screen.getByRole('link', { name: 'View details' }), + ).toHaveAttribute('href', jobs[4].href), + ); + expect(screen.getByRole('tab', { name: 'Engineer 4' })).toHaveFocus(); + fireEvent.keyDown(document.activeElement!, { key: 'Home' }); + expect(screen.getByRole('heading')).toHaveTextContent('Engineer 0'); + }); + + it('fetches updated details when the same job belongs to a new completed run', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json(jobs[1])) + .mockResolvedValueOnce( + Response.json({ ...jobs[1], title: 'Updated Engineer' }), + ); + vi.stubGlobal('fetch', fetch); + const { rerender, props } = renderStack(jobs, [jobs[0]]); + fireEvent.click(screen.getByRole('tab', { name: 'Engineer 1' })); + await waitFor(() => + expect( + screen.getByRole('link', { name: 'View details' }), + ).toHaveAttribute('href', jobs[1].href), + ); + rerender(); + await waitFor(() => + expect(screen.getByRole('heading')).toHaveTextContent('Updated Engineer'), + ); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('does not display a late response for a previously selected job', async () => { + let completeFirst!: (response: Response) => void; + const fetch = vi.fn().mockImplementation((url: string) => + url.endsWith('/1') + ? new Promise((resolve) => { + completeFirst = resolve; + }) + : Promise.resolve(Response.json(jobs[2])), + ); + vi.stubGlobal('fetch', fetch); + renderStack(jobs, [jobs[0]]); + fireEvent.click(screen.getByRole('tab', { name: 'Engineer 1' })); + fireEvent.click(screen.getByRole('tab', { name: 'Engineer 2' })); + await waitFor(() => + expect( + screen.getByRole('link', { name: 'View details' }), + ).toHaveAttribute('href', jobs[2].href), + ); + await act(async () => completeFirst(Response.json(jobs[1]))); + expect(screen.getByRole('heading')).toHaveTextContent('Engineer 2'); + expect(screen.getByRole('link', { name: 'View details' })).toHaveAttribute( + 'href', + jobs[2].href, + ); + }); + + it.each(['server error', 'wrong job', 'invalid card'])( + 'keeps navigation available and offers the job page after %s', + async (failure) => { + const response = + failure === 'server error' + ? Response.json({ error: 'SQL secret' }, { status: 500 }) + : failure === 'wrong job' + ? Response.json(jobs[2]) + : Response.json({ id: jobs[1].id }); + const fetch = vi.fn().mockResolvedValue(response); + vi.stubGlobal('fetch', fetch); + renderStack(jobs, [jobs[0]]); + fireEvent.click(screen.getByRole('tab', { name: 'Engineer 1' })); + expect( + await screen.findByRole('link', { name: 'View job details' }), + ).toHaveAttribute('href', jobs[1].href); + expect(screen.queryByText(/SQL secret|Job lookup mismatch/)).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('tab', { name: 'Engineer 0' })); + expect(screen.getByRole('heading')).toHaveTextContent('Engineer 0'); + }, + ); +}); diff --git a/src/features/jobs/components/job-list/job-stack.tsx b/src/features/jobs/components/job-list/job-stack.tsx index 8a2a231..61f3271 100644 --- a/src/features/jobs/components/job-list/job-stack.tsx +++ b/src/features/jobs/components/job-list/job-stack.tsx @@ -1,6 +1,13 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { + useEffect, + useId, + useMemo, + useRef, + useState, + type TouchEvent, +} from 'react'; import { ChevronLeftIcon, ChevronRightIcon, @@ -10,84 +17,228 @@ import { Button } from '@/components/ui/button'; import { LinkWithLoader } from '@/components/link-with-loader'; import { cn } from '@/lib/utils'; import type { JobListItemSchema } from '@/features/jobs/schemas'; -import { JobListItem } from './job-list-item'; +import type { JobStackTitle } from '@/features/jobs/job-stack'; +import { JobListItem, JobListItemFrame } from './job-list-item'; +import { useStackJob } from './use-stack-job'; + +interface JobStackProps { + jobs: JobListItemSchema[]; + jobTitles: JobStackTitle[]; + importRunId: string | null; +} + +export const JobStack = ({ jobs, jobTitles, importRunId }: JobStackProps) => { + const [selectedId, setSelectedId] = useState(jobTitles[0].id); + const found = jobTitles.findIndex((item) => item.id === selectedId); + const index = Math.max(0, found); + const selected = jobTitles[index]; + const { job, failed, prefetch } = useStackJob(selected.id, importRunId, jobs); + const organization = jobs[0].organization; + const strip = useRef(null); + const activeTitle = useRef(null); + const card = useRef(null); + const previousHeight = useRef(undefined); + const gesture = useRef<{ x: number; y: number } | null>(null); + const panelId = useId(); + const hasPager = jobTitles.length > 1; + const navigationLabel = `Jobs at ${organization?.name ?? 'this organization'}`; + const allJobsLabel = `All jobs at ${organization?.name ?? 'this organization'}`; + const layers = Math.min(3, jobTitles.length - 1); + const duplicateTitles = useMemo(() => { + const counts = new Map(); + jobTitles.forEach(({ title }) => + counts.set(title, (counts.get(title) ?? 0) + 1), + ); + return new Set( + [...counts].filter(([, count]) => count > 1).map(([title]) => title), + ); + }, [jobTitles]); + + useEffect(() => { + if (found < 0) setSelectedId(jobTitles[0].id); + }, [found, jobTitles]); + + useEffect(() => { + if (job && card.current) + previousHeight.current = card.current.getBoundingClientRect().height; + }, [job]); -export const JobStack = ({ jobs }: { jobs: JobListItemSchema[] }) => { - const [selectedId, setSelectedId] = useState(jobs[0].id); - const found = jobs.findIndex((job) => job.id === selectedId); - const index = found < 0 ? 0 : found; useEffect(() => { - if (!jobs.some((item) => item.id === selectedId)) setSelectedId(jobs[0].id); - }, [jobs, selectedId]); - const job = jobs[index]; - const layers = jobs.length - 1; - const navigationLabel = `Jobs at ${job.organization?.name ?? 'this organization'}`; - const positionLabel = (position: number) => - `Show job ${position + 1} of ${jobs.length}`; - const select = (position: number) => setSelectedId(jobs[position].id); + const element = strip.current; + if (!element) return; + const revealSelection = () => { + const button = activeTitle.current; + if (!button) return; + const viewport = element.getBoundingClientRect(); + const bounds = button.getBoundingClientRect(); + const delta = + bounds.left < viewport.left + ? bounds.left - viewport.left + : bounds.right > viewport.right + ? bounds.right - viewport.right + : 0; + if (delta) element.scrollLeft += delta; + }; + revealSelection(); + if (typeof ResizeObserver === 'undefined') return; + let frame: number | undefined; + const observer = new ResizeObserver(() => { + revealSelection(); + // The title widths change with the strip. Recheck after the browser has + // adjusted its horizontal scroll position for that new layout. + if (frame !== undefined) cancelAnimationFrame(frame); + frame = requestAnimationFrame(revealSelection); + }); + observer.observe(element); + if (activeTitle.current) observer.observe(activeTitle.current); + return () => { + observer.disconnect(); + if (frame !== undefined) cancelAnimationFrame(frame); + }; + }, [selected.id, jobTitles]); + + const select = (position: number, focus = false) => { + const next = Math.max(0, Math.min(jobTitles.length - 1, position)); + setSelectedId(jobTitles[next].id); + if (focus) + strip.current + ?.querySelectorAll('[role="tab"]') + [next]?.focus({ preventScroll: true }); + }; + const onTouchStart = (event: TouchEvent) => { + const target = event.target as HTMLElement; + if ( + event.touches.length !== 1 || + target.closest( + 'a, button, input, textarea, select, [role="tablist"], [role="slider"], [contenteditable="true"]', + ) + ) { + gesture.current = null; + return; + } + gesture.current = { + x: event.touches[0].clientX, + y: event.touches[0].clientY, + }; + }; + const onTouchEnd = (event: TouchEvent) => { + const start = gesture.current; + gesture.current = null; + if (!start || event.changedTouches.length !== 1) return; + const dx = event.changedTouches[0].clientX - start.x; + const dy = event.changedTouches[0].clientY - start.y; + if (Math.abs(dx) >= 50 && Math.abs(dx) > Math.abs(dy) * 1.5) + select(index + (dx < 0 ? 1 : -1)); + }; const footer = ( -
- {jobs.length > 1 && ( -