diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index ce22b11..c30f3fb 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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" } diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index a601bd0..96da857 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -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" } diff --git a/apps/web/src/lib/components/TagChip.svelte b/apps/web/src/lib/components/TagChip.svelte new file mode 100644 index 0000000..ac7587d --- /dev/null +++ b/apps/web/src/lib/components/TagChip.svelte @@ -0,0 +1,67 @@ + + +{#if onSelect} + +{:else} + + {name} + {#if onRemove} + + {/if} + +{/if} diff --git a/apps/web/src/lib/components/TagPicker.svelte b/apps/web/src/lib/components/TagPicker.svelte new file mode 100644 index 0000000..49728dd --- /dev/null +++ b/apps/web/src/lib/components/TagPicker.svelte @@ -0,0 +1,130 @@ + + +
+ {#if showSelected && selectedTags.length > 0} +
+ {#each selectedTags as tag (tag.id)} + handleToggle(tag)} + testid="tag-picker-selected" + /> + {/each} +
+ {/if} + + + +
+ {#each result.matches as tag (tag.id)} + {@const isSelected = selectedIds.includes(tag.id)} + + {/each} + + {#if result.canCreate && onCreate} + + {/if} + + {#if result.matches.length === 0 && !result.canCreate} +

{m.tag_picker_empty()}

+ {/if} +
+
diff --git a/apps/web/src/lib/components/tag-picker-filter.test.ts b/apps/web/src/lib/components/tag-picker-filter.test.ts new file mode 100644 index 0000000..a7a5678 --- /dev/null +++ b/apps/web/src/lib/components/tag-picker-filter.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/lib/components/tag-picker-filter.ts b/apps/web/src/lib/components/tag-picker-filter.ts new file mode 100644 index 0000000..b3a3964 --- /dev/null +++ b/apps/web/src/lib/components/tag-picker-filter.ts @@ -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 + }; +} diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index edcf1e1..f0222b4 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -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 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.