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:
67
apps/web/src/lib/components/TagChip.svelte
Normal file
67
apps/web/src/lib/components/TagChip.svelte
Normal 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}
|
||||
130
apps/web/src/lib/components/TagPicker.svelte
Normal file
130
apps/web/src/lib/components/TagPicker.svelte
Normal 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>
|
||||
58
apps/web/src/lib/components/tag-picker-filter.test.ts
Normal file
58
apps/web/src/lib/components/tag-picker-filter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
45
apps/web/src/lib/components/tag-picker-filter.ts
Normal file
45
apps/web/src/lib/components/tag-picker-filter.ts
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user