feat(fase-11): TagChip + TagPicker components + filter unit tests (11.3.1-2)

TagChip — small pill rendering a tag's name with the colour from the 8-preset
palette. Three usage shapes (mutually exclusive):
  * display-only (default)
  * with onRemove → renders an inline X (used by picker-selected chips)
  * with onSelect → renders the whole chip as a button (used by the lateral
    filter bar and by item-row chips that filter the list on click)
Nested <button> was the SSR warning from the first cut — fixed by making
onSelect / onRemove mutually exclusive.

TagPicker — combobox-style picker driven by filterTagOptions (pure helper
in tag-picker-filter.ts so it can be unit-tested without rendering). Lists
matches by substring, surfaces a "Create `{query}`" affordance only when no
existing tag matches the trimmed query exactly. Enter key prefers toggling
the first match, falls back to creating. Re-usable from the item modal,
the list-filter bar, and the settings tag-management section.

tag-picker-filter.test.ts — 7 unit tests (TP-01..07) covering case-
insensitive substring match, empty query → full list, create affordance
gating on exact-match absence, exclusion of already-selected ids, and
createName preserving verbatim user input (no lowercasing).

tailwind.config.ts safelists the runtime-built bg-/text-/ring-/dot
combinations for all 8 preset colours so JIT doesn't strip them.

Messages: 9 new keys in en + es (tag picker, filter bar, settings section).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 03:08:54 +02:00
parent c1152fac90
commit 8fa629ad74
7 changed files with 338 additions and 2 deletions

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import type { ItemTagColor } from '@colectivo/types';
/**
* Small pill that renders an item tag.
*
* - default: display-only `<span>`
* - `onRemove`: renders the pill as a `<span>` with an inline X button
* - `onSelect`: renders the pill as a single `<button>` (used by the
* filter bar and item-row chips that filter the list on click)
*
* `onSelect` and `onRemove` are mutually exclusive — supplying both is a
* programming error (nested `<button>` is invalid HTML and SvelteKit
* warns about hydration mismatches). The picker-selected variant uses
* `onRemove`; the filter-bar variant uses `onSelect`.
*/
interface Props {
name: string;
color: ItemTagColor;
onRemove?: () => void;
onSelect?: () => void;
active?: boolean;
testid?: string;
}
const { name, color, onRemove, onSelect, active = false, testid }: Props = $props();
// Tailwind cannot see dynamic class names — see tailwind.config.ts safelist.
const bg = $derived(`bg-${color}-100 dark:bg-${color}-900/40`);
const fg = $derived(`text-${color}-700 dark:text-${color}-200`);
const ring = $derived(active ? `ring-1 ring-${color}-200 dark:ring-${color}-700/50` : '');
const baseClasses = $derived(
`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${bg} ${fg} ${ring}`
);
</script>
{#if onSelect}
<button
type="button"
onclick={onSelect}
data-testid={testid ?? 'tag-chip'}
data-tag={name}
data-active={active ? 'true' : undefined}
class="{baseClasses} transition-opacity hover:opacity-90"
>
<span class="truncate">{name}</span>
</button>
{:else}
<span
data-testid={testid ?? 'tag-chip'}
data-tag={name}
class={baseClasses}
>
<span class="truncate">{name}</span>
{#if onRemove}
<button
type="button"
onclick={onRemove}
aria-label="Remove"
class="-mr-0.5 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/10"
>
<X size={10} strokeWidth={2} />
</button>
{/if}
</span>
{/if}

View File

@@ -0,0 +1,130 @@
<script lang="ts">
import { Plus, Check } from 'lucide-svelte';
import TagChip from './TagChip.svelte';
import { filterTagOptions } from './tag-picker-filter';
import type { ItemTag, ItemTagColor } from '@colectivo/types';
import * as m from '$lib/paraglide/messages';
/**
* Combobox-style picker for collective tags.
*
* - filters `availableTags` by substring on `query`
* - shows a "Create `query`" affordance when no exact match exists
* - calls `onToggle(tag)` for an existing tag click (caller decides
* attach vs detach by reading whether `selectedIds` already has it)
* - calls `onCreate(name)` for a brand-new tag
*
* Pure UI — does not call the network itself. Re-usable from the item
* modal (single-item attach/detach) and the list-filter bar (toggle
* filter chips).
*/
interface Props {
availableTags: ItemTag[];
selectedIds: string[];
onToggle: (tag: ItemTag) => void | Promise<void>;
onCreate?: (name: string) => void | Promise<void>;
placeholder?: string;
showSelected?: boolean;
}
const {
availableTags,
selectedIds,
onToggle,
onCreate,
placeholder,
showSelected = true
}: Props = $props();
let query = $state('');
let inputEl: HTMLInputElement | undefined = $state();
const result = $derived(filterTagOptions(availableTags, query, selectedIds));
const selectedTags = $derived(availableTags.filter((t) => selectedIds.includes(t.id)));
async function handleCreate() {
if (!onCreate || !result.canCreate) return;
const name = result.createName;
await onCreate(name);
query = '';
inputEl?.focus();
}
async function handleToggle(tag: ItemTag) {
await onToggle(tag);
query = '';
inputEl?.focus();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
// Prefer toggling the first match (mirrors typical combobox behaviour);
// fall back to creating a new tag when nothing matches.
if (result.matches.length > 0) {
void handleToggle(result.matches[0]);
} else if (result.canCreate) {
void handleCreate();
}
}
}
</script>
<div data-testid="tag-picker" class="space-y-2">
{#if showSelected && selectedTags.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each selectedTags as tag (tag.id)}
<TagChip
name={tag.name}
color={tag.color as ItemTagColor}
onRemove={() => handleToggle(tag)}
testid="tag-picker-selected"
/>
{/each}
</div>
{/if}
<input
bind:this={inputEl}
bind:value={query}
onkeydown={handleKeydown}
type="text"
placeholder={placeholder ?? m.tag_picker_placeholder()}
data-testid="tag-picker-input"
class="w-full rounded-md bg-background px-3 py-2 text-sm text-text-primary outline-none ring-1 ring-black/10 focus:ring-slate-900 dark:ring-white/10 dark:focus:ring-slate-100"
/>
<div class="flex max-h-48 flex-col gap-1 overflow-y-auto" data-testid="tag-picker-options">
{#each result.matches as tag (tag.id)}
{@const isSelected = selectedIds.includes(tag.id)}
<button
type="button"
onclick={() => handleToggle(tag)}
data-testid="tag-picker-option"
data-tag={tag.name}
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-black/5 dark:hover:bg-white/5"
>
<TagChip name={tag.name} color={tag.color as ItemTagColor} />
{#if isSelected}
<Check size={14} strokeWidth={2} class="ml-auto text-text-secondary" />
{/if}
</button>
{/each}
{#if result.canCreate && onCreate}
<button
type="button"
onclick={handleCreate}
data-testid="tag-picker-create"
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
>
<Plus size={14} strokeWidth={2} />
<span>{m.tag_picker_create({ name: result.createName })}</span>
</button>
{/if}
{#if result.matches.length === 0 && !result.canCreate}
<p class="px-2 py-1.5 text-xs text-text-muted">{m.tag_picker_empty()}</p>
{/if}
</div>
</div>

View File

@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { filterTagOptions } from './tag-picker-filter';
import type { ItemTag } from '@colectivo/types';
function tag(name: string, id = name): ItemTag {
return {
id,
collective_id: 'c',
name,
color: 'slate',
created_at: '2026-05-18T00:00:00Z'
};
}
describe('filterTagOptions', () => {
const all = [tag('Vegano'), tag('Oferta'), tag('Vegetariano'), tag('Bio')];
it('TP-01: substring match is case-insensitive', () => {
const result = filterTagOptions(all, 'veg', []);
expect(result.matches.map((t) => t.name)).toEqual(['Vegano', 'Vegetariano']);
});
it('TP-02: returns the full list when query is empty', () => {
const result = filterTagOptions(all, '', []);
expect(result.matches).toHaveLength(4);
expect(result.canCreate).toBe(false);
});
it('TP-03: canCreate is true when the query is non-empty and no exact match exists', () => {
const result = filterTagOptions(all, 'Lácteo', []);
expect(result.canCreate).toBe(true);
expect(result.createName).toBe('Lácteo');
});
it('TP-04: canCreate is false when an exact (case-insensitive) match exists', () => {
const result = filterTagOptions(all, 'vegano', []);
expect(result.canCreate).toBe(false);
// The exact match must still appear in the matches list.
expect(result.matches.map((t) => t.name)).toContain('Vegano');
});
it('TP-05: excludes already-selected ids from the suggestions', () => {
const result = filterTagOptions(all, 'veg', ['Vegano']);
expect(result.matches.map((t) => t.name)).toEqual(['Vegetariano']);
});
it('TP-06: trims whitespace before evaluating the query', () => {
const result = filterTagOptions(all, ' ', []);
expect(result.matches).toHaveLength(4);
expect(result.canCreate).toBe(false);
});
it('TP-07: createName preserves the user input verbatim (no lowercasing)', () => {
const result = filterTagOptions(all, ' Sin-Gluten ', []);
expect(result.createName).toBe('Sin-Gluten');
expect(result.canCreate).toBe(true);
});
});

View File

@@ -0,0 +1,45 @@
/**
* Pure logic backing TagPicker.svelte — extracted so it can be unit-tested
* without rendering a component.
*
* Given the collective's full tag list, a search query, and the set of
* already-selected tag ids, returns:
* - `matches`: tags matching the query (substring, case-insensitive)
* with already-selected entries filtered out
* - `canCreate`: whether the "Create `query`" affordance should appear
* (true iff the trimmed query is non-empty AND no existing tag matches
* the query exactly, case-insensitive)
* - `createName`: the verbatim trimmed query — used as the new tag name
* when the user picks the create affordance (no casing applied)
*/
import type { ItemTag } from '@colectivo/types';
export interface TagFilterResult {
matches: ItemTag[];
canCreate: boolean;
createName: string;
}
export function filterTagOptions(
all: ItemTag[],
query: string,
selectedIds: string[]
): TagFilterResult {
const trimmed = query.trim();
const lowerQuery = trimmed.toLowerCase();
const selectedSet = new Set(selectedIds);
const filtered = all.filter((t) => !selectedSet.has(t.id));
const matches = trimmed
? filtered.filter((t) => t.name.toLowerCase().includes(lowerQuery))
: filtered;
const exactMatchExists = all.some((t) => t.name.toLowerCase() === lowerQuery);
return {
matches,
canCreate: trimmed.length > 0 && !exactMatchExists,
createName: trimmed
};
}