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.
142 lines
5.0 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|