Files
job-search/web/src/components/MarkMenu.tsx
T
bwilliams 3bb2711276 Added the closing date to the table, and narrowed its columns to fit
The backend's new closing_date, the bid deadline, is a Closing column
before the start and end dates, which are now the contract's: the
details dialog calls them Contract start and Contract end. Closing
sorts soonest first, with no date last, and shows the time it closes
in the cell's tooltip and in the dialog. The API types are regenerated
for it.

To make room, the SME suitable column is now an SME badge beside the
status, shown only when it's suitable, so the Status column sorts by
status alone and a stored SME sort goes back to the default. The Mark
column is narrower. An opportunity without a status is shown as
Unknown, in the colours of the statuses without their own, such as
Cancelled and Withdrawn, rather than as a blank badge or a dash.
2026-10-21 14:47:56 +01:00

142 lines
5.0 KiB
TypeScript

import type { JSX } from 'solid-js';
import { createEffect, createSignal, createUniqueId, For, onCleanup, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
import type { OpportunityMark } from '@/types/Annotations';
import { MARK_LABELS, OPPORTUNITY_MARKS } from '@/types/Annotations';
import { MARK_BG } from './styles';
interface Props {
marks: OpportunityMark[];
onToggle: (mark: OpportunityMark) => void;
/** Used in the button's accessible name. */
opportunityTitle: string;
}
const MENU_WIDTH = 200;
const GAP = 4;
/**
* A dropdown of mark checkboxes. Each mark toggles on its own and the menu
* stays open, so several can be set in one go. The menu is portalled to
* <body> with fixed positioning so the table's scroll container can't clip it.
*/
export function MarkMenu(props: Props): JSX.Element {
const [isOpen, setIsOpen] = createSignal(false);
const [pos, setPos] = createSignal({ top: 0, left: 0, isAbove: false });
const menuId = createUniqueId();
let button!: HTMLButtonElement;
let menu: HTMLDivElement | undefined;
const place = () => {
const rect = button.getBoundingClientRect();
const height = menu?.offsetHeight ?? 220;
const isAbove = rect.bottom + GAP + height > window.innerHeight && rect.top - GAP - height > 0;
const left = Math.min(Math.max(8, rect.right - MENU_WIDTH), window.innerWidth - MENU_WIDTH - 8);
setPos({ top: isAbove ? rect.top - GAP - height : rect.bottom + GAP, left, isAbove });
};
const close = (shouldRefocus = false) => {
setIsOpen(false);
if (shouldRefocus) button.focus();
};
createEffect(() => {
if (!isOpen()) return;
place();
requestAnimationFrame(() => {
place(); // re-measure now the menu has a real height
menu?.querySelector<HTMLInputElement>('input')?.focus();
});
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (!button.contains(target) && !menu?.contains(target)) close();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close(true);
};
const onScroll = (e: Event) => {
if (!menu?.contains(e.target as Node)) close();
};
document.addEventListener('pointerdown', onPointer);
document.addEventListener('keydown', onKey);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', place);
onCleanup(() => {
document.removeEventListener('pointerdown', onPointer);
document.removeEventListener('keydown', onKey);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', place);
});
});
const summary = (): string =>
props.marks.length === 0
? 'not marked'
: props.marks.map((mark) => MARK_LABELS[mark]).join(', ');
return (
<>
<button
ref={button}
type="button"
class="flex min-h-9 w-36 items-center gap-2 rounded-md border border-line bg-surface px-2 py-1 text-left hover:border-muted"
aria-haspopup="true"
aria-expanded={isOpen()}
aria-controls={isOpen() ? menuId : undefined}
aria-label={`Marks for ${props.opportunityTitle}: ${summary()}`}
onClick={() => setIsOpen(!isOpen())}
>
<span class="flex min-w-0 flex-1 flex-wrap gap-1">
<Show when={props.marks.length > 0} fallback={<span class="text-muted">Not marked</span>}>
<For each={props.marks}>
{(mark) => (
<span class="inline-flex items-center gap-1 rounded bg-paper px-1.5 text-xs leading-5 whitespace-nowrap text-ink">
<span class={`size-2 rounded-full ${MARK_BG[mark]}`} aria-hidden="true" />
{MARK_LABELS[mark]}
</span>
)}
</For>
</Show>
</span>
<svg
viewBox="0 0 16 16"
class="size-3.5 shrink-0 text-muted"
fill="currentColor"
aria-hidden="true"
>
<path d="M3.5 6 8 10.5 12.5 6z" />
</svg>
</button>
<Show when={isOpen()}>
<Portal>
<div
ref={menu}
id={menuId}
role="group"
aria-label={`Marks for ${props.opportunityTitle}`}
class="fixed z-50 rounded-lg border border-line bg-surface p-1 text-ink shadow-[0_8px_24px_rgb(0_0_0/0.18)]"
style={{ top: `${pos().top}px`, left: `${pos().left}px`, width: `${MENU_WIDTH}px` }}
>
<For each={OPPORTUNITY_MARKS}>
{(mark) => (
<label class="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-paper">
<input
type="checkbox"
checked={props.marks.includes(mark)}
onChange={() => props.onToggle(mark)}
/>
<span class={`size-2.5 rounded-full ${MARK_BG[mark]}`} aria-hidden="true" />
{MARK_LABELS[mark]}
</label>
)}
</For>
</div>
</Portal>
</Show>
</>
);
}