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:
@@ -220,5 +220,15 @@
|
|||||||
"manage_dissolve_confirm_button": "Dissolve permanently",
|
"manage_dissolve_confirm_button": "Dissolve permanently",
|
||||||
"sidebar_create_collective": "+ New collective",
|
"sidebar_create_collective": "+ New collective",
|
||||||
"create_collective_modal_title": "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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,5 +220,15 @@
|
|||||||
"manage_dissolve_confirm_button": "Disolver permanentemente",
|
"manage_dissolve_confirm_button": "Disolver permanentemente",
|
||||||
"sidebar_create_collective": "+ Nuevo colectivo",
|
"sidebar_create_collective": "+ Nuevo colectivo",
|
||||||
"create_collective_modal_title": "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"
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,23 @@
|
|||||||
import type { Config } from 'tailwindcss';
|
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 {
|
export default {
|
||||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||||
|
safelist: tagSafelist,
|
||||||
// Fase 9.1: drive dark mode from <html data-theme="dark"> so the inline
|
// 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
|
// anti-FOUC script in app.html can set the attribute synchronously before
|
||||||
// the first paint. Existing `dark:` utilities keep working as-is.
|
// the first paint. Existing `dark:` utilities keep working as-is.
|
||||||
|
|||||||
Reference in New Issue
Block a user