Skip to content
Merged
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
220 changes: 178 additions & 42 deletions src/features/jobs/components/job-list/job-stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
useRef,
useState,
type TouchEvent,
type PointerEvent,
} from 'react';
import {
ChevronLeftIcon,
Expand Down Expand Up @@ -38,7 +39,25 @@ export const JobStack = ({ jobs, jobTitles, importRunId }: JobStackProps) => {
const activeTitle = useRef<HTMLButtonElement>(null);
const card = useRef<HTMLDivElement>(null);
const previousHeight = useRef<number | undefined>(undefined);
const gesture = useRef<{ x: number; y: number } | null>(null);
const gesture = useRef<{
x: number;
y: number;
horizontal: boolean;
prefetchedDirection: number;
} | null>(null);
const [swipeOffset, setSwipeOffset] = useState(0);
const [swipePhase, setSwipePhase] = useState<
'idle' | 'dragging' | 'entering' | 'settling'
>('idle');
const swipeFrame = useRef<number | undefined>(undefined);

useEffect(
() => () => {
if (swipeFrame.current !== undefined)
cancelAnimationFrame(swipeFrame.current);
},
[],
);
const panelId = useId();
const hasPager = jobTitles.length > 1;
const navigationLabel = `Jobs at ${organization?.name ?? 'this organization'}`;
Expand Down Expand Up @@ -105,37 +124,100 @@ export const JobStack = ({ jobs, jobTitles, importRunId }: JobStackProps) => {
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
[next]?.focus({ preventScroll: true });
};
const onTouchStart = (event: TouchEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
const startSwipe = (target: HTMLElement, x: number, y: number) => {
if (
event.touches.length !== 1 ||
!hasPager ||
target.closest(
'a, button, input, textarea, select, [role="tablist"], [role="slider"], [contenteditable="true"]',
'a, button, input, textarea, select, summary, [role="button"], [role="tablist"], [role="slider"], [contenteditable="true"]',
)
) {
gesture.current = null;
)
return;
if (swipeFrame.current !== undefined)
cancelAnimationFrame(swipeFrame.current);
gesture.current = { x, y, horizontal: false, prefetchedDirection: 0 };
};
const settleSwipe = () => {
if (swipeFrame.current !== undefined)
cancelAnimationFrame(swipeFrame.current);
gesture.current = null;
setSwipePhase('settling');
setSwipeOffset(0);
};
const moveSwipe = (x: number, y: number) => {
const start = gesture.current;
if (!start) return;
const dx = x - start.x;
const dy = y - start.y;
if (!start.horizontal) {
if (Math.abs(dy) > 8 && Math.abs(dy) >= Math.abs(dx)) {
settleSwipe();
return;
}
if (Math.abs(dx) < 8 || Math.abs(dx) <= Math.abs(dy) * 1.5) return;
start.horizontal = true;
}
gesture.current = {
x: event.touches[0].clientX,
y: event.touches[0].clientY,
};
const direction = dx < 0 ? 1 : -1;
const next = index + direction;
const hasNext = next >= 0 && next < jobTitles.length;
if (hasNext && start.prefetchedDirection !== direction) {
prefetch(jobTitles[next].id);
start.prefetchedDirection = direction;
}
const width = card.current?.clientWidth ?? 320;
setSwipePhase('dragging');
// Give the first and last cards resistance instead of suggesting another job.
setSwipeOffset(
hasNext ? Math.max(-width * 0.65, Math.min(width * 0.65, dx)) : dx * 0.15,
);
};
const onTouchEnd = (event: TouchEvent<HTMLDivElement>) => {
const finishSwipe = (x: number, y: number) => {
const start = gesture.current;
if (!start) return;
const dx = x - start.x;
const dy = y - start.y;
const direction = dx < 0 ? 1 : -1;
const next = index + direction;
const width = card.current?.clientWidth ?? 320;
const threshold = Math.max(50, Math.min(100, width * 0.2));
if (
Math.abs(dx) < threshold ||
Math.abs(dx) <= Math.abs(dy) * 1.5 ||
next < 0 ||
next >= jobTitles.length
) {
settleSwipe();
return;
}
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));
select(next);
// Bring the next card in from the side the user is revealing.
setSwipePhase('entering');
setSwipeOffset(direction * Math.min(width * 0.3, 120));
swipeFrame.current = requestAnimationFrame(() => {
swipeFrame.current = requestAnimationFrame(() => {
setSwipePhase('settling');
setSwipeOffset(0);
});
});
};
const onTouchStart = (event: TouchEvent<HTMLDivElement>) => {
if (event.touches.length !== 1) return settleSwipe();
startSwipe(
event.target as HTMLElement,
event.touches[0].clientX,
event.touches[0].clientY,
);
};
const onMousePointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'mouse' && event.button === 0)
startSwipe(event.target as HTMLElement, event.clientX, event.clientY);
};
const footer = (
<div className='flex min-w-0 flex-wrap items-center gap-x-2 border-t border-border/50 px-2 py-1 text-xs text-muted-foreground @xl:flex-nowrap @xl:px-3 @xl:text-sm'>
<div className='flex min-w-0 flex-wrap items-center border-t border-border/50 px-2 text-xs text-muted-foreground @xl:text-sm @3xl:flex-nowrap @3xl:gap-x-3 @3xl:px-3'>
{hasPager && (
<nav
aria-label={navigationLabel}
className='flex min-w-0 basis-full items-center @xl:flex-1 @xl:basis-auto'
className='flex min-w-0 basis-full items-center @3xl:flex-1 @3xl:basis-0'
>
<Button
type='button'
Expand Down Expand Up @@ -219,35 +301,52 @@ export const JobStack = ({ jobs, jobTitles, importRunId }: JobStackProps) => {
</Button>
</nav>
)}
{hasPager && (
<div
className={cn(
'flex w-full min-w-0 items-center py-2 @3xl:w-auto @3xl:max-w-[45%] @3xl:shrink-0',
hasPager && 'border-t border-border/50 @3xl:border-t-0',
)}
>
<span
role='status'
aria-live='polite'
aria-atomic='true'
className='shrink-0 pl-2 whitespace-nowrap tabular-nums @xl:pl-0'
className='min-w-0 shrink-0 px-2 leading-snug'
>
<span className='sr-only'>{selected.title}, </span>
{index + 1} of {jobTitles.length}
<span className='block font-medium text-foreground tabular-nums'>
{hasPager ? `${index + 1} of ${jobTitles.length}` : '1'}{' '}
</span>
<span className='block'>
{hasPager ? 'new jobs found' : 'new job found'}
</span>
</span>
)}
{organization && (
<Button
asChild
variant='ghost'
size='sm'
className='ml-auto h-8 max-w-[75%] min-w-0 px-2 text-xs @xl:max-w-[30%] @xl:text-sm'
>
<LinkWithLoader href={organization.href} aria-label={allJobsLabel}>
<span className='truncate'>All jobs at {organization.name}</span>
<ArrowRightIcon className='shrink-0' />
</LinkWithLoader>
</Button>
)}
{organization && (
<div className='ml-3 min-w-0 flex-1 border-l border-border pl-3 @3xl:ml-2 @3xl:pl-2'>
<Button
asChild
variant='ghost'
size='sm'
className='h-auto min-h-9 w-full min-w-0 justify-end gap-2 px-2 py-1 text-right text-xs whitespace-normal @xl:text-sm'
>
<LinkWithLoader
href={organization.href}
aria-label={allJobsLabel}
>
<span className='min-w-0 [overflow-wrap:anywhere]'>
View all jobs at {organization.name}
</span>
<ArrowRightIcon className='shrink-0' />
</LinkWithLoader>
</Button>
</div>
)}
</div>
</div>
);
return (
<div
className='@container relative isolate min-w-0'
className='@container relative isolate min-w-0 overflow-x-clip'
style={{ paddingBottom: layers * 3 }}
>
{Array.from({ length: layers }, (_, i) => layers - i).map((layer) => (
Expand All @@ -263,14 +362,51 @@ export const JobStack = ({ jobs, jobTitles, importRunId }: JobStackProps) => {
/>
))}
<div
className='relative min-w-0'
className='relative min-w-0 motion-reduce:transform-none! motion-reduce:opacity-100! motion-reduce:transition-none!'
style={{
transform: swipeOffset
? `translateX(${swipeOffset}px) rotate(${Math.max(-3, Math.min(3, swipeOffset / 60))}deg)`
: undefined,
transformOrigin: '50% 80%',
transition:
swipePhase === 'settling'
? 'transform 240ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease-out'
: 'none',
opacity: swipePhase === 'entering' ? 0.65 : 1,
}}
onTransitionEnd={(event) => {
if (
event.target === event.currentTarget &&
event.propertyName === 'transform'
)
setSwipePhase('idle');
}}
onTouchStart={onTouchStart}
onTouchMove={(event) => {
if (event.touches.length !== 1) gesture.current = null;
if (event.touches.length !== 1) return settleSwipe();
moveSwipe(event.touches[0].clientX, event.touches[0].clientY);
}}
onTouchEnd={(event) => {
if (event.changedTouches.length !== 1) return settleSwipe();
finishSwipe(
event.changedTouches[0].clientX,
event.changedTouches[0].clientY,
);
}}
onTouchCancel={settleSwipe}
onPointerDown={onMousePointerDown}
onPointerMove={(event) => {
if (event.pointerType !== 'mouse' || !gesture.current) return;
moveSwipe(event.clientX, event.clientY);
if (gesture.current?.horizontal)
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerUp={(event) => {
if (event.pointerType === 'mouse')
finishSwipe(event.clientX, event.clientY);
}}
onTouchEnd={onTouchEnd}
onTouchCancel={() => {
gesture.current = null;
onPointerCancel={(event) => {
if (event.pointerType === 'mouse') settleSwipe();
}}
>
<JobListItemFrame>
Expand Down
Loading