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

@@ -220,5 +220,15 @@
"manage_dissolve_confirm_button": "Dissolve permanently",
"sidebar_create_collective": "+ New collective",
"create_collective_modal_title": "New collective",
"create_collective_modal_button": "Create"
"create_collective_modal_button": "Create",
"tag_picker_placeholder": "Search or create tag",
"tag_picker_create": "Create \"{name}\"",
"tag_picker_empty": "No tags yet.",
"tag_picker_label": "Tags",
"list_filter_by_tag": "Filter by tag",
"list_filter_clear": "Clear",
"settings_tags_section": "Tags",
"settings_tags_empty": "No tags yet — create one from any list.",
"settings_tags_delete": "Delete",
"settings_tags_color": "Color"
}

View File

@@ -220,5 +220,15 @@
"manage_dissolve_confirm_button": "Disolver permanentemente",
"sidebar_create_collective": "+ Nuevo colectivo",
"create_collective_modal_title": "Nuevo colectivo",
"create_collective_modal_button": "Crear"
"create_collective_modal_button": "Crear",
"tag_picker_placeholder": "Buscar o crear etiqueta",
"tag_picker_create": "Crear «{name}»",
"tag_picker_empty": "Aún no hay etiquetas.",
"tag_picker_label": "Etiquetas",
"list_filter_by_tag": "Filtrar por etiqueta",
"list_filter_clear": "Limpiar",
"settings_tags_section": "Etiquetas",
"settings_tags_empty": "Aún no hay etiquetas — créalas desde cualquier lista.",
"settings_tags_delete": "Eliminar",
"settings_tags_color": "Color"
}

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
};
}

View File

@@ -1,7 +1,23 @@
import type { Config } from 'tailwindcss';
// Fase 11: tag colours are picked at runtime from an 8-preset palette
// (see public.item_tags.color CHECK constraint). Tailwind cannot statically
// see the class names (they're built from the colour string), so safelist
// every (variant × intensity) combination the chip + dot use.
const TAG_COLORS = ['slate', 'red', 'amber', 'green', 'sky', 'indigo', 'pink', 'stone'];
const tagSafelist = TAG_COLORS.flatMap((c) => [
`bg-${c}-100`,
`text-${c}-700`,
`ring-${c}-200`,
`dark:bg-${c}-900/40`,
`dark:text-${c}-200`,
`dark:ring-${c}-700/50`,
`bg-${c}-500`
]);
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
safelist: tagSafelist,
// Fase 9.1: drive dark mode from <html data-theme="dark"> so the inline
// anti-FOUC script in app.html can set the attribute synchronously before
// the first paint. Existing `dark:` utilities keep working as-is.