diff --git a/src/components/art-tray.js b/src/components/art-tray.js new file mode 100644 index 0000000..b18a95f --- /dev/null +++ b/src/components/art-tray.js @@ -0,0 +1,53 @@ +import { searchArt } from '../art/search.js'; +import artManifest from '../art/manifest.js'; +import { escapeHtml } from '../core/escape-html.js'; + +export class ArtTray extends HTMLElement { + constructor() { + super(); + this.manifest = artManifest; + this._query = ''; + } + connectedCallback() { this.render(); } + + render() { + const results = searchArt(this.manifest, this._query); + const grid = results.map((a) => ` + `).join(''); + + this.innerHTML = ` +
+ +
${grid || '
No matches
'}
+
`; + + const input = this.querySelector('[data-role=art-query]'); + input.addEventListener('input', () => { this._query = input.value; this.renderResults(); }); + this.bindThumbs(); + } + + // Re-render only the results (keeps input focus/caret stable while typing). + renderResults() { + const results = searchArt(this.manifest, this._query); + const container = this.querySelector('[data-role=art-results]'); + container.innerHTML = results.map((a) => ` + `).join('') || '
No matches
'; + this.bindThumbs(); + } + + bindThumbs() { + this.querySelectorAll('[data-art-id]').forEach((btn) => { + btn.addEventListener('click', () => { + const asset = this.manifest.find((a) => a.id === btn.dataset.artId); + if (asset) this.dispatchEvent(new CustomEvent('show-prop', { detail: { src: asset.src, label: asset.label }, bubbles: true })); + }); + }); + } +} +customElements.define('art-tray', ArtTray); diff --git a/tests/components/art-tray.test.js b/tests/components/art-tray.test.js new file mode 100644 index 0000000..69531e3 --- /dev/null +++ b/tests/components/art-tray.test.js @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/art-tray.js'; + +const M = [ + { id: 'a', src: '/art/a.png', label: 'Detective in alley', tags: ['detective', 'alley'] }, + { id: 'b', src: '/art/b.png', label: 'Neon street', tags: ['street', 'neon'] }, +]; + +describe('', () => { + let el; + beforeEach(() => { + document.body.innerHTML = ''; + el = document.createElement('art-tray'); + el.manifest = M; + document.body.appendChild(el); + }); + + it('shows the whole manifest before any search', () => { + expect(el.querySelectorAll('[data-art-id]').length).toBe(2); + }); + + it('filters results as the query changes', () => { + const input = el.querySelector('[data-role=art-query]'); + input.value = 'detective'; + input.dispatchEvent(new Event('input')); + const ids = [...el.querySelectorAll('[data-art-id]')].map((n) => n.dataset.artId); + expect(ids).toEqual(['a']); + }); + + it('emits show-prop when a thumbnail is clicked', () => { + let detail = null; + el.addEventListener('show-prop', (e) => { detail = e.detail; }); + el.querySelector('[data-art-id="b"]').click(); + expect(detail).toEqual({ src: '/art/b.png', label: 'Neon street' }); + }); + + it('escapes a query containing a quote (input does not break)', () => { + const input = el.querySelector('[data-role=art-query]'); + input.value = 'rain " '; + input.dispatchEvent(new Event('input')); + expect(el.querySelector('[data-role=art-query]').value).toBe('rain " '); + }); +});