Made the classification picker a tree, where ticking a code selects its whole branch
The classifications are grouped by kind (CPV, then Other) and, within CPV, nested by the CPV hierarchy: each code sits under its nearest ancestor in the list, so one whose parent isn't in use isn't lost. The other codes have no hierarchy, so they're listed flat. Branches start closed; a search shows the matching codes with their ancestors, opened when no more than 150 match. Ticking a code selects it and every code beneath it, and ticking a whole selected branch clears it. The checkboxes are tri-state, showing a dash when only part of a branch is selected, and a closed branch that is partly selected says how many codes beneath it are. The selection above the tree shows a whole selected branch as one chip, with the number of codes beneath it, and removing that chip clears the branch. The filter is unchanged: a set of classification ids, so a branch is the codes in the picker when it's ticked.
This commit is contained in:
1 parent
4db268550b
commit
ff2aa316b5
4 files changed
+573
-46
No files matched your search
@@ -1,10 +1,105 @@
|
||||
import ChevronRight from 'lucide-solid/icons/chevron-right';
|
||||
import type { JSX } from 'solid-js';
|
||||
import { createMemo, createSignal, createUniqueId, For, Show } from 'solid-js';
|
||||
|
||||
import type {
|
||||
BranchSelection,
|
||||
ClassificationGroup,
|
||||
ClassificationNode,
|
||||
SelectionChip,
|
||||
} from '@/lib/classifications';
|
||||
import {
|
||||
branchIds,
|
||||
branchSelection,
|
||||
classificationMatcher,
|
||||
classificationTree,
|
||||
countSelected,
|
||||
filterClassificationTree,
|
||||
selectionChips,
|
||||
toggleBranch,
|
||||
} from '@/lib/classifications';
|
||||
import { formatCount } from '@/lib/format';
|
||||
import type { ClassificationCode } from '@/types/Opportunities';
|
||||
import { fieldLabel, input, linkButton } from './styles';
|
||||
import { TriStateCheckbox } from './TriStateCheckbox';
|
||||
|
||||
const MAX_VISIBLE = 150;
|
||||
// A search matching more codes than this leaves the tree closed, as opening
|
||||
// every branch would make a very long list.
|
||||
const MAX_OPENED = 150;
|
||||
|
||||
const toggleButton = 'grid size-5 place-items-center rounded text-muted hover:bg-paper';
|
||||
const chevron = 'size-4 transition-transform duration-200 motion-reduce:transition-none';
|
||||
const selectedCount = 'rounded-full bg-accent-soft px-2 text-xs leading-5 text-accent';
|
||||
|
||||
interface BranchProps {
|
||||
node: ClassificationNode;
|
||||
selected: ReadonlySet<number>;
|
||||
/** How much of the whole branch under the classification with this id is selected. */
|
||||
selection: (id: number) => BranchSelection;
|
||||
/** Called when the checkbox of the classification with this id is ticked or cleared. */
|
||||
onToggle: (id: number) => void;
|
||||
/** Whether the branch with this key (a classification id) is open. */
|
||||
isOpen: (key: string) => boolean;
|
||||
onToggleOpen: (key: string) => void;
|
||||
}
|
||||
|
||||
// One classification in the tree, with its checkbox (for its whole branch)
|
||||
// and, when it has codes beneath it, a button to show or hide them.
|
||||
function ClassificationBranch(props: BranchProps): JSX.Element {
|
||||
const code = (): ClassificationCode => props.node.code;
|
||||
const key = (): string => String(code().id);
|
||||
const hasChildren = (): boolean => props.node.children.length > 0;
|
||||
const childrenId = createUniqueId();
|
||||
const selectedBeneath = (): number => countSelected(props.node.children, props.selected);
|
||||
const selection = (): BranchSelection => props.selection(code().id);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-1 rounded px-1 hover:bg-paper">
|
||||
<Show when={hasChildren()} fallback={<span class="size-5" />}>
|
||||
<button
|
||||
type="button"
|
||||
class={toggleButton}
|
||||
aria-expanded={props.isOpen(key())}
|
||||
aria-controls={childrenId}
|
||||
aria-label={`Codes under ${code().code}`}
|
||||
onClick={() => props.onToggleOpen(key())}
|
||||
>
|
||||
<ChevronRight class={chevron} classList={{ 'rotate-90': props.isOpen(key()) }} />
|
||||
</button>
|
||||
</Show>
|
||||
<label class="grid min-w-0 cursor-pointer grid-cols-[auto_auto_1fr] items-baseline gap-2 py-1">
|
||||
<TriStateCheckbox
|
||||
checked={selection() === 'all'}
|
||||
indeterminate={selection() === 'some'}
|
||||
onChange={() => props.onToggle(code().id)}
|
||||
/>
|
||||
<span class="text-sm text-muted tabular-nums">{code().code}</span>
|
||||
<span class="truncate">{code().classification}</span>
|
||||
</label>
|
||||
<Show when={!props.isOpen(key()) && selection() === 'some' && selectedBeneath() > 0}>
|
||||
<span class={selectedCount}>{selectedBeneath()} selected</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={hasChildren() && props.isOpen(key())}>
|
||||
<ul id={childrenId} class="pl-5">
|
||||
<For each={props.node.children}>
|
||||
{(child) => (
|
||||
<ClassificationBranch
|
||||
node={child}
|
||||
selected={props.selected}
|
||||
selection={(id) => props.selection(id)}
|
||||
onToggle={(id) => props.onToggle(id)}
|
||||
isOpen={(childKey) => props.isOpen(childKey)}
|
||||
onToggleOpen={(childKey) => props.onToggleOpen(childKey)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
classifications: ClassificationCode[];
|
||||
@@ -16,30 +111,71 @@ interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
/** A searchable list of classifications to filter by, with the chosen ones above it. */
|
||||
/**
|
||||
* A searchable tree of classifications to filter by, grouped by kind (CPV
|
||||
* or other) and, within CPV, by the CPV hierarchy; the chosen ones are
|
||||
* above it. Ticking a classification selects it and every code beneath it.
|
||||
*/
|
||||
export function ClassificationPicker(props: Props): JSX.Element {
|
||||
const [query, setQuery] = createSignal('');
|
||||
// Group keys and classification ids (as strings) of the open branches.
|
||||
const [opened, setOpened] = createSignal<ReadonlySet<string>>(new Set());
|
||||
const inputId = createUniqueId();
|
||||
|
||||
const sorted = createMemo(() =>
|
||||
[...props.classifications].sort((a, b) => a.code.localeCompare(b.code)),
|
||||
);
|
||||
const byId = createMemo(() => new Map(sorted().map((c) => [c.id, c])));
|
||||
const groups = createMemo(() => classificationTree(props.classifications));
|
||||
// The full tree's nodes by id: a search shows only part of each branch,
|
||||
// but ticking a classification still selects its whole branch.
|
||||
const nodesById = createMemo(() => {
|
||||
const nodes = new Map<number, ClassificationNode>();
|
||||
const add = (node: ClassificationNode): void => {
|
||||
nodes.set(node.code.id, node);
|
||||
node.children.forEach(add);
|
||||
};
|
||||
for (const group of groups()) group.nodes.forEach(add);
|
||||
return nodes;
|
||||
});
|
||||
const chips = createMemo(() => selectionChips(groups(), props.selected));
|
||||
|
||||
const matches = createMemo(() => {
|
||||
const q = query().trim().toLocaleLowerCase();
|
||||
if (!q) return sorted();
|
||||
return sorted().filter(
|
||||
(code) =>
|
||||
code.code.toLocaleLowerCase().startsWith(q) ||
|
||||
code.classification.toLocaleLowerCase().includes(q),
|
||||
);
|
||||
const search = createMemo(() => {
|
||||
if (!query().trim()) return undefined;
|
||||
const matches = classificationMatcher(query());
|
||||
|
||||
let count = 0;
|
||||
const found: ClassificationGroup[] = [];
|
||||
for (const group of groups()) {
|
||||
const filtered = filterClassificationTree(group.nodes, matches);
|
||||
count += filtered.count;
|
||||
if (filtered.nodes.length > 0) found.push({ ...group, nodes: filtered.nodes });
|
||||
}
|
||||
return { groups: found, count };
|
||||
});
|
||||
|
||||
const shownGroups = (): ClassificationGroup[] => search()?.groups ?? groups();
|
||||
const isSearchOpened = (): boolean => (search()?.count ?? Infinity) <= MAX_OPENED;
|
||||
const isOpen = (key: string): boolean => isSearchOpened() || opened().has(key);
|
||||
|
||||
const toggleOpen = (key: string): void => {
|
||||
const next = new Set(opened());
|
||||
if (isOpen(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
setOpened(next);
|
||||
};
|
||||
|
||||
const selection = (id: number): BranchSelection => {
|
||||
const node = nodesById().get(id);
|
||||
return node ? branchSelection(node, props.selected) : 'none';
|
||||
};
|
||||
|
||||
const toggle = (id: number): void => {
|
||||
const node = nodesById().get(id);
|
||||
if (node) props.onChange(toggleBranch(node, props.selected));
|
||||
};
|
||||
|
||||
const removeChip = (chip: SelectionChip): void => {
|
||||
const next = new Set(props.selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
for (const id of chip.node && chip.isBranch ? branchIds(chip.node) : [chip.id]) {
|
||||
next.delete(id);
|
||||
}
|
||||
props.onChange(next);
|
||||
};
|
||||
|
||||
@@ -53,19 +189,30 @@ export function ClassificationPicker(props: Props): JSX.Element {
|
||||
|
||||
<Show when={props.selected.size > 0}>
|
||||
<ul class="mb-2 flex flex-wrap gap-1.5" aria-label="Selected classifications">
|
||||
<For each={[...props.selected]}>
|
||||
{(id) => {
|
||||
const code = (): ClassificationCode | undefined => byId().get(id);
|
||||
<For each={chips()}>
|
||||
{(chip) => {
|
||||
const code = (): ClassificationCode | undefined => chip.node?.code;
|
||||
const beneath = (): number => (chip.node ? branchIds(chip.node).length - 1 : 0);
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex max-w-72 items-baseline gap-1.5 rounded-md bg-accent-soft px-2 py-1 text-accent"
|
||||
onClick={() => toggle(id)}
|
||||
title="Remove this classification"
|
||||
onClick={() => removeChip(chip)}
|
||||
title={
|
||||
chip.isBranch
|
||||
? 'Remove this classification and those beneath it'
|
||||
: 'Remove this classification'
|
||||
}
|
||||
>
|
||||
<span class="text-sm tabular-nums">{code()?.code ?? id}</span>
|
||||
<span class="text-sm tabular-nums">{code()?.code ?? chip.id}</span>
|
||||
<span class="truncate">{code()?.classification ?? 'Unknown'}</span>
|
||||
<Show when={chip.isBranch}>
|
||||
<span class="text-sm whitespace-nowrap">
|
||||
+{formatCount(beneath())}
|
||||
<span class="sr-only"> beneath it</span>
|
||||
</span>
|
||||
</Show>
|
||||
<span aria-hidden="true" class="font-bold">
|
||||
×
|
||||
</span>
|
||||
@@ -108,35 +255,59 @@ export function ClassificationPicker(props: Props): JSX.Element {
|
||||
aria-label="Classifications"
|
||||
>
|
||||
<For
|
||||
each={matches().slice(0, MAX_VISIBLE)}
|
||||
each={shownGroups()}
|
||||
fallback={
|
||||
<li class="p-1.5 text-sm text-muted">No classifications match “{query()}”.</li>
|
||||
}
|
||||
>
|
||||
{(code) => (
|
||||
<li>
|
||||
<label class="grid cursor-pointer grid-cols-[auto_auto_1fr_auto] items-baseline gap-2 rounded px-1.5 py-1 hover:bg-paper">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.selected.has(code.id)}
|
||||
onChange={() => toggle(code.id)}
|
||||
/>
|
||||
<span class="text-sm text-muted tabular-nums">{code.code}</span>
|
||||
<span class="truncate">{code.classification}</span>
|
||||
<Show when={code.is_cpv}>
|
||||
<span class="text-xs text-muted" title="Common Procurement Vocabulary">
|
||||
CPV
|
||||
</span>
|
||||
{(group) => {
|
||||
const key = (): string => `group:${group.key}`;
|
||||
const nodesId = createUniqueId();
|
||||
const selectedInGroup = (): number => countSelected(group.nodes, props.selected);
|
||||
return (
|
||||
<li>
|
||||
<div class="flex items-center gap-1 rounded px-1 hover:bg-paper">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex grow items-center gap-1 py-1 text-left font-semibold"
|
||||
aria-expanded={isOpen(key())}
|
||||
aria-controls={nodesId}
|
||||
onClick={() => toggleOpen(key())}
|
||||
>
|
||||
<span class="grid size-5 place-items-center text-muted">
|
||||
<ChevronRight class={chevron} classList={{ 'rotate-90': isOpen(key()) }} />
|
||||
</span>
|
||||
<span title={group.title}>{group.label}</span>
|
||||
</button>
|
||||
<Show when={!isOpen(key()) && selectedInGroup() > 0}>
|
||||
<span class={selectedCount}>{selectedInGroup()} selected</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={isOpen(key())}>
|
||||
<ul id={nodesId} class="pl-5">
|
||||
<For each={group.nodes}>
|
||||
{(node) => (
|
||||
<ClassificationBranch
|
||||
node={node}
|
||||
selected={props.selected}
|
||||
selection={selection}
|
||||
onToggle={toggle}
|
||||
isOpen={isOpen}
|
||||
onToggleOpen={toggleOpen}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</label>
|
||||
</li>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
<Show when={matches().length > MAX_VISIBLE}>
|
||||
<Show when={search() && !isSearchOpened()}>
|
||||
<p class="mt-1.5 text-sm text-muted">
|
||||
Showing {MAX_VISIBLE} of {matches().length.toLocaleString('en-GB')}. Type more to narrow
|
||||
the list.
|
||||
{formatCount(search()?.count ?? 0)} match, too many to open them all. Type more to
|
||||
narrow the list, or open the branches you want.
|
||||
</p>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { JSX } from 'solid-js';
|
||||
import { createEffect, splitProps } from 'solid-js';
|
||||
|
||||
interface Props extends Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'type' | 'ref'> {
|
||||
/** Shown as a dash, and announced as mixed, in place of checked. */
|
||||
indeterminate: boolean;
|
||||
}
|
||||
|
||||
/** A checkbox that can also show a third, indeterminate state. */
|
||||
export function TriStateCheckbox(props: Props): JSX.Element {
|
||||
const [local, inputProps] = splitProps(props, ['indeterminate']);
|
||||
let checkbox!: HTMLInputElement;
|
||||
|
||||
// indeterminate is a property with no HTML attribute, so it's set on the
|
||||
// element rather than in the markup.
|
||||
createEffect(() => {
|
||||
checkbox.indeterminate = local.indeterminate;
|
||||
});
|
||||
|
||||
return <input ref={checkbox} type="checkbox" {...inputProps} />;
|
||||
}
|
||||
Reference in new issue
Block a user