From cb72ec005dd34817ebe2b02573c9937dd470ebd4 Mon Sep 17 00:00:00 2001 From: David Leonardi Date: Tue, 29 Sep 2026 12:01:56 +0200 Subject: [PATCH 1/2] feat: fit organization job stacks to width and support swiping --- .../components/job-list/job-stack.test.tsx | 69 ++++++- .../jobs/components/job-list/job-stack.tsx | 176 +++++++++++++----- .../jobs/server/data/fetch-job-feed.ts | 3 +- 3 files changed, 199 insertions(+), 49 deletions(-) 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..5582ced 100644 --- a/src/features/jobs/components/job-list/job-stack.test.tsx +++ b/src/features/jobs/components/job-list/job-stack.test.tsx @@ -1,5 +1,11 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { JobListItemSchema } from '@/features/jobs/schemas'; import { JobStack } from './job-stack'; @@ -25,7 +31,10 @@ vi.mock('@/components/link-with-loader', () => ({ ), })); vi.mock('@/lib/analytics', () => ({ GA_EVENT: {}, trackEvent: vi.fn() })); -afterEach(cleanup); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); const jobs = Array.from( { length: 5 }, (_, index) => @@ -73,3 +82,59 @@ describe('organization card stack', () => { expect(getPageHref(1, {}, '/o-acme~org-acme')).toBe('/o-acme~org-acme'); }); }); + +it('fits up to 25 jobs, clamps selection when narrowed, and keeps layers shallow', () => { + let resize!: (entries: { contentRect: { width: number } }[]) => void; + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: typeof resize) { + resize = callback; + } + observe() {} + disconnect() {} + }, + ); + const many = Array.from({ length: 30 }, (_, i) => ({ + ...jobs[0], + id: String(i), + title: `Engineer ${i}`, + })); + const { container } = render(); + act(() => resize([{ contentRect: { width: 720 } }])); + expect(screen.getAllByRole('button', { name: /Show job/ })).toHaveLength(25); + fireEvent.click(screen.getByRole('button', { name: 'Show job 25 of 25' })); + act(() => resize([{ contentRect: { width: 240 } }])); + expect(screen.getByRole('status')).toHaveTextContent('10 of 10'); + expect(screen.getByRole('heading')).toHaveTextContent('Engineer 9'); + act(() => resize([{ contentRect: { width: 120 } }])); + expect(screen.getByRole('status')).toHaveTextContent('5 of 5'); + expect(container.querySelectorAll('div[aria-hidden="true"]')).toHaveLength(3); + act(() => resize([{ contentRect: { width: 600 } }])); + expect(screen.getByRole('status')).toHaveTextContent('5 of 25'); + expect(container.querySelectorAll('article')).toHaveLength(1); +}); + +it('swipes both ways without changing cards during vertical scrolling or control use', () => { + render(); + const card = screen.getByRole('heading'); + 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 }], + }); + }; + swipe(card, -100); + expect(screen.getByRole('status')).toHaveTextContent('2 of 5'); + swipe(card, -100, 150); + expect(screen.getByRole('status')).toHaveTextContent('2 of 5'); + swipe(screen.getByRole('link'), -100); + expect(screen.getByRole('status')).toHaveTextContent('2 of 5'); + swipe(card, 100); + swipe(card, 100); + expect(screen.getByRole('status')).toHaveTextContent('1 of 5'); + 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'); +}); diff --git a/src/features/jobs/components/job-list/job-stack.tsx b/src/features/jobs/components/job-list/job-stack.tsx index 8a2a231..e7b79c9 100644 --- a/src/features/jobs/components/job-list/job-stack.tsx +++ b/src/features/jobs/components/job-list/job-stack.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useRef, useState, type TouchEvent } from 'react'; import { ChevronLeftIcon, ChevronRightIcon, @@ -13,59 +13,124 @@ import type { JobListItemSchema } from '@/features/jobs/schemas'; import { JobListItem } from './job-list-item'; export const JobStack = ({ jobs }: { jobs: JobListItemSchema[] }) => { + const [capacity, setCapacity] = useState(5); + const hasPager = jobs.length > 1; + const pager = useRef(null); + const gesture = useRef<{ x: number; y: number } | null>(null); + const visibleJobs = useMemo(() => jobs.slice(0, capacity), [jobs, capacity]); 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 element = pager.current; + if (!element || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(([entry]) => { + // Each dot keeps a 24px target. Measure the space left after the other controls. + setCapacity( + Math.max(1, Math.min(25, Math.floor(entry.contentRect.width / 24))), + ); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [hasPager]); + const found = visibleJobs.findIndex((job) => job.id === selectedId); + const index = + found < 0 + ? Math.min( + jobs.findIndex((item) => item.id === selectedId), + visibleJobs.length - 1, + ) + : found; + const safeIndex = Math.max(0, index); + useEffect(() => { + if (!visibleJobs.some((item) => item.id === selectedId)) + setSelectedId(visibleJobs[safeIndex].id); + }, [visibleJobs, selectedId, safeIndex]); + const job = visibleJobs[safeIndex]; + const layers = Math.min(3, visibleJobs.length - 1); + const allJobsLabel = `View all jobs at ${job.organization?.name ?? 'this organization'}`; 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); + `Show job ${position + 1} of ${visibleJobs.length}`; + const select = (position: number) => + setSelectedId( + visibleJobs[Math.max(0, Math.min(visibleJobs.length - 1, position))].id, + ); + const onTouchStart = (event: TouchEvent) => { + const target = event.target as HTMLElement; + if ( + event.touches.length !== 1 || + target.closest( + 'a, button, input, textarea, select, [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(safeIndex + (dx < 0 ? 1 : -1)); + }; const footer = ( -
- {jobs.length > 1 && ( -