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:
bwilliams committed 2026-10-21 12:39:22 +01:00
1 parent 4db268550b
commit ff2aa316b5
4 files changed
+573 -46

No files matched your search

+214 -43
View File
@@ -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>
+21
View File
@@ -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} />;
}