feat(collections): multi-select tag filter + per-collection tag counts (TASK-1666) (#668)

Add a TagFilter chip row to the collection page FilterBar — one toggle
chip per tag present in the collection, each showing its item count, with
OR selection semantics. Tag counts are derived client-side from the local
index (no network round-trip) and honor the showArchived toggle. Selection
round-trips through the `tags` URL param and the saved-view ViewConfig
(rides along as a single `op: 'in'` filter).

Implements TASK-1666.
This commit is contained in:
xarmian
2026-05-30 15:30:54 -04:00
committed by GitHub
parent 0d5f96657b
commit 1be42e5406
3 changed files with 204 additions and 8 deletions
@@ -2,6 +2,7 @@
import type { Collection } from '$lib/types';
import { parseSchema } from '$lib/types';
import BottomSheet from '$lib/components/common/BottomSheet.svelte';
import TagFilter from '$lib/components/collections/TagFilter.svelte';
interface Props {
collection: Collection;
@@ -10,10 +11,26 @@
onFilterChange: (filters: Record<string, string>) => void;
onSearchChange: (query: string) => void;
relationLabels?: Record<string, string>;
/** Tags present in this collection, with counts, ordered by count desc. */
tagCounts?: { tag: string; count: number }[];
/** Currently-selected tag filters (OR semantics). */
selectedTags?: string[];
onTagFilterChange?: (tags: string[]) => void;
searchInputEl?: HTMLInputElement | undefined;
}
let { collection, activeFilters, searchQuery, onFilterChange, onSearchChange, relationLabels = {}, searchInputEl = $bindable() }: Props = $props();
let {
collection,
activeFilters,
searchQuery,
onFilterChange,
onSearchChange,
relationLabels = {},
tagCounts = [],
selectedTags = [],
onTagFilterChange = () => {},
searchInputEl = $bindable(),
}: Props = $props();
let schema = $derived(parseSchema(collection));
let statusField = $derived(schema.fields.find((f) => f.key === 'status'));
@@ -162,6 +179,10 @@
{/if}
{/if}
{#if tagCounts.length > 0}
<TagFilter tags={tagCounts} selected={selectedTags} onchange={onTagFilterChange} />
{/if}
<div class="search-wrapper">
<!--
Search hits the local in-memory index (titles + parsed fields)
@@ -0,0 +1,109 @@
<script lang="ts">
/**
* Multi-select tag filter for the collection page. Renders one toggle
* chip per tag present in the *current collection*, each showing the
* tag's item count within that collection. Selection is OR semantics —
* the caller shows items carrying ANY selected tag.
*
* Counts are computed client-side from the local index (the collection
* page already holds every row's tags), so this is workspace-agnostic
* and needs no network round-trip. Chip styling mirrors
* `fields/TagInput.svelte` for visual consistency.
*/
interface Props {
/** Collection-scoped tags with counts, ordered by count desc. */
tags: { tag: string; count: number }[];
/** Currently-selected tag values. */
selected: string[];
onchange: (selected: string[]) => void;
}
let { tags, selected, onchange }: Props = $props();
let selectedSet = $derived(new Set(selected));
function toggle(tag: string) {
if (selectedSet.has(tag)) {
onchange(selected.filter((t) => t !== tag));
} else {
onchange([...selected, tag]);
}
}
function clearAll() {
onchange([]);
}
</script>
{#if tags.length > 0}
<div class="tag-filter" role="group" aria-label="Filter by tag">
{#each tags as { tag, count } (tag)}
{@const active = selectedSet.has(tag)}
<button
type="button"
class="tag-chip"
class:active
aria-pressed={active}
onclick={() => toggle(tag)}
>
<span class="tag-name">{tag}</span>
<span class="tag-count">{count}</span>
</button>
{/each}
{#if selected.length > 0}
<button type="button" class="tag-clear" onclick={clearAll}>Clear tags</button>
{/if}
</div>
{/if}
<style>
.tag-filter {
display: flex;
flex-wrap: wrap;
gap: var(--space-1, 0.25rem);
align-items: center;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 0.4em;
padding: 0.1em 0.5em;
font-size: var(--text-xs, 0.75rem);
line-height: 1.5;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-primary);
white-space: nowrap;
cursor: pointer;
}
.tag-chip:hover:not(.active) {
background: var(--bg-hover, var(--bg-tertiary));
}
.tag-chip.active {
background: var(--accent-blue, #3b82f6);
border-color: var(--accent-blue, #3b82f6);
color: #fff;
font-weight: 600;
}
.tag-count {
font-variant-numeric: tabular-nums;
opacity: 0.7;
font-size: 0.92em;
}
.tag-chip.active .tag-count {
opacity: 0.85;
}
.tag-clear {
background: none;
border: none;
padding: 0.1em 0.4em;
font-size: var(--text-xs, 0.75rem);
color: var(--text-secondary);
cursor: pointer;
text-decoration: underline;
}
.tag-clear:hover {
color: var(--text-primary);
}
</style>
@@ -3,7 +3,7 @@
import { goto } from '$app/navigation';
import { api, PadApiError, isPlanLimitError, planLimitMessage } from '$lib/api/client';
import type { Collection, Item, QuickAction, View, ViewConfig } from '$lib/types';
import { parseSettings, parseFields, parseSchema, getStatusOptions, itemUrlId, formatItemRef } from '$lib/types';
import { parseSettings, parseFields, parseSchema, parseTags, getStatusOptions, itemUrlId, formatItemRef } from '$lib/types';
import BoardView from '$lib/components/collections/BoardView.svelte';
import ListView from '$lib/components/collections/ListView.svelte';
import TableView from '$lib/components/collections/TableView.svelte';
@@ -39,6 +39,10 @@
let collection = $state<Collection | null>(null);
let viewMode = $state<ViewMode>('list');
let activeFilters = $state<Record<string, string>>({});
// Multi-select tag filter (OR semantics). Tags live on the top-level
// `tags` column, not in `fields` JSON, so they're tracked separately
// from `activeFilters` and applied in their own `filteredItems` branch.
let selectedTags = $state<string[]>([]);
let searchQuery = $state('');
let showArchived = $state(false);
let itemProgress = $state<Record<string, { total: number; done: number }>>({});
@@ -201,6 +205,10 @@
for (const [k, v] of Object.entries(activeFilters)) {
params.set(k, v);
}
// Tags are comma-joined under a single `tags` param. The tag editor
// uses comma as its add-delimiter, so tag values never contain
// commas — round-tripping through a comma-joined string is safe.
if (selectedTags.length > 0) params.set('tags', selectedTags.join(','));
if (searchQuery) params.set('q', searchQuery);
const qs = params.toString();
const newUrl = `/${username}/${wsSlug}/${collSlug}${qs ? '?' + qs : ''}`;
@@ -211,12 +219,18 @@
function loadUrlFilters() {
const url = new URL(page.url);
const filters: Record<string, string> = {};
const knownParams = new Set(['view', 'q']);
// Reset tags up-front so navigating to a collection whose URL has no
// `tags` param clears any selection carried over from the previous
// collection (absent = cleared).
selectedTags = [];
const knownParams = new Set(['view', 'q', 'tags']);
for (const [k, v] of url.searchParams.entries()) {
if (k === 'view' && (v === 'list' || v === 'board')) {
viewMode = v;
} else if (k === 'q') {
searchQuery = v;
} else if (k === 'tags') {
selectedTags = v.split(',').map((t) => t.trim()).filter(Boolean);
} else if (!knownParams.has(k)) {
filters[k] = v;
}
@@ -527,6 +541,18 @@
});
}
// Apply tag filter (OR semantics): keep items carrying ANY selected
// tag. Tags are a top-level column (not in `fields` JSON), so this
// is its own branch — mirroring the `parent`/`phase` special-case
// above. `parseTags` tolerates the JSON-array-string shape the
// local index stores.
if (selectedTags.length > 0) {
const wanted = new Set(selectedTags);
result = result.filter((item) =>
parseTags(item).some((t) => wanted.has(t)),
);
}
// Apply search query. PLAN-1343 Phase 3b: the local path
// populates `searchResultRank` synchronously (sub-ms) so the
// filter just intersects. The substring fallback below covers
@@ -578,6 +604,24 @@
return counts;
});
// Per-collection tag counts for the filter chips. Computed client-side
// from `items` (already collection-scoped via localIndex, and already
// honoring the `showArchived` toggle) — no network round-trip, and the
// counts reflect the full collection so each chip's number is stable
// regardless of which other tags are selected (OR semantics). Ordered
// by count desc then tag asc to match the workspace tags page.
let tagCounts = $derived.by(() => {
const counts = new Map<string, number>();
for (const item of items) {
for (const tag of parseTags(item)) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
return [...counts.entries()]
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
});
const emptyHintMap: Record<string, string> = {
tasks: '/pad break down my current work into tasks',
ideas: "/pad I have an idea for...",
@@ -591,7 +635,7 @@
let emptyHint = $derived(emptyHintMap[collSlug] ?? null);
let filtersOpen = $state(false);
let hasActiveFilters = $derived(searchQuery.trim() !== '' || Object.keys(activeFilters).length > 0);
let hasActiveFilters = $derived(searchQuery.trim() !== '' || Object.keys(activeFilters).length > 0 || selectedTags.length > 0);
// ── Viewport detection ───────────────────────────────────────────────
// On mobile the 3-icon view toggle (list/board/table) is swapped for a
@@ -642,6 +686,11 @@
updateUrlFilters();
}
function handleTagFilterChange(tags: string[]) {
selectedTags = tags;
updateUrlFilters();
}
function handleSearchChange(query: string) {
// Pure setter — the reactive effect below runs the actual search.
// Keeping this small means non-input entry points (URL load via
@@ -1229,8 +1278,17 @@
function buildViewConfig(): ViewConfig {
const config: ViewConfig = {};
const filterEntries = Object.entries(activeFilters);
if (filterEntries.length > 0) {
config.filters = filterEntries.map(([field, value]) => ({ field, op: 'eq', value }));
const filters: NonNullable<ViewConfig['filters']> = filterEntries.map(
([field, value]) => ({ field, op: 'eq', value }),
);
// Tags ride along as a single `in` filter (OR semantics) with an
// array value — the ViewFilter shape already supports `op: 'in'`
// + an array `value`.
if (selectedTags.length > 0) {
filters.push({ field: 'tags', op: 'in', value: [...selectedTags] });
}
if (filters.length > 0) {
config.filters = filters;
}
return config;
}
@@ -1249,19 +1307,23 @@
// Apply filters
const newFilters: Record<string, string> = {};
let newTags: string[] = [];
if (config.filters) {
for (const f of config.filters) {
if (f.op === 'eq' && typeof f.value === 'string') {
if (f.field === 'tags' && f.op === 'in') {
newTags = Array.isArray(f.value) ? f.value : [f.value];
} else if (f.op === 'eq' && typeof f.value === 'string') {
newFilters[f.field] = f.value;
}
}
}
activeFilters = newFilters;
selectedTags = newTags;
searchQuery = '';
searchResultRank = null;
// Open filters panel if the view has filters
if (Object.keys(newFilters).length > 0) {
if (Object.keys(newFilters).length > 0 || newTags.length > 0) {
filtersOpen = true;
}
@@ -1272,6 +1334,7 @@
function clearActiveView() {
activeViewId = null;
activeFilters = {};
selectedTags = [];
searchQuery = '';
searchResultRank = null;
filtersOpen = false;
@@ -1535,6 +1598,9 @@
onFilterChange={handleFilterChange}
onSearchChange={handleSearchChange}
{relationLabels}
{tagCounts}
{selectedTags}
onTagFilterChange={handleTagFilterChange}
bind:searchInputEl
/>
</div>