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 = `
+
`;
+
+ 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 " ');
+ });
+});