feat: <art-tray> pencil-art library search UI with HTML escaping
This commit is contained in:
@@ -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) => `
|
||||
<button class="thumb" data-art-id="${escapeHtml(a.id)}" title="${escapeHtml(a.label)}">
|
||||
<img src="${escapeHtml(a.src)}" alt="${escapeHtml(a.label)}" loading="lazy" />
|
||||
<span class="thumb-cap">${escapeHtml(a.label)}</span>
|
||||
</button>`).join('');
|
||||
|
||||
this.innerHTML = `
|
||||
<div class="art-tray">
|
||||
<input class="art-query" data-role="art-query" placeholder="search pencil art — e.g. rain, detective" value="${escapeHtml(this._query)}" />
|
||||
<div class="art-results" data-role="art-results">${grid || '<div class="art-empty">No matches</div>'}</div>
|
||||
</div>`;
|
||||
|
||||
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) => `
|
||||
<button class="thumb" data-art-id="${escapeHtml(a.id)}" title="${escapeHtml(a.label)}">
|
||||
<img src="${escapeHtml(a.src)}" alt="${escapeHtml(a.label)}" loading="lazy" />
|
||||
<span class="thumb-cap">${escapeHtml(a.label)}</span>
|
||||
</button>`).join('') || '<div class="art-empty">No matches</div>';
|
||||
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);
|
||||
@@ -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('<art-tray>', () => {
|
||||
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 " <x>';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
expect(el.querySelector('[data-role=art-query]').value).toBe('rain " <x>');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user