feat(web): add DiscoverResultCard with reserved badge slot + anchored button
T14 of the M5a Lidarr plan. The Discover grid in M5a renders mixed
requestable / kept / requested cards from Lidarr search. Without
layout discipline the cards in a row land their titles at different
Y coordinates whenever the badge presence varies, which reads as
visual noise. DiscoverResultCard enforces:
- Flex column with `margin-top: auto` on `.actions`, so the action
button is anchored to the bottom of the card body regardless of
title/subtitle wrap differences.
- `.badge-row` always rendered with `min-height: 22px`, so the
title baseline holds even when no Kept pill is present.
Both load-bearing CSS values use inline style attributes — jsdom's
getComputedStyle does not resolve scoped <style> blocks, so the tests
verify positioning via inline styles directly. The remaining decorative
CSS (kept-pill background = accent at 15%, flex-direction, gap) lives
in a scoped <style> block.
State -> action mapping (sentence case per FabledSword voice):
requestable -> bg-action-primary "Request" with Plus icon
kept -> disabled ghost "In library" + Kept pill
requested -> disabled ghost "Requested"
Cover art falls back to a Lucide glyph (Disc3 / Album / Music2) when
imageUrl is absent. Adds lucide-svelte@^1.0.1 dependency.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Generated
+11
-1
@@ -8,7 +8,8 @@
|
|||||||
"name": "minstrel-web",
|
"name": "minstrel-web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/svelte-query": "^5.90.2"
|
"@tanstack/svelte-query": "^5.90.2",
|
||||||
|
"lucide-svelte": "^1.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
@@ -2637,6 +2638,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-svelte": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": "^3 || ^4 || ^5.0.0-next.42"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -28,6 +28,7 @@
|
|||||||
"vitest": "^2.1.4"
|
"vitest": "^2.1.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/svelte-query": "^5.90.2"
|
"@tanstack/svelte-query": "^5.90.2",
|
||||||
|
"lucide-svelte": "^1.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export type DiscoverCardKind = 'artist' | 'album' | 'track';
|
||||||
|
export type DiscoverCardState = 'requestable' | 'kept' | 'requested';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Plus, Disc3, Album, Music2 } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
imageUrl,
|
||||||
|
state,
|
||||||
|
onRequest,
|
||||||
|
}: {
|
||||||
|
kind: DiscoverCardKind;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
state: DiscoverCardState;
|
||||||
|
onRequest?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const FallbackIcon = $derived(
|
||||||
|
kind === 'artist' ? Disc3 : kind === 'album' ? Album : Music2
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleRequest() {
|
||||||
|
if (state === 'requestable') onRequest?.();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article
|
||||||
|
class="card rounded-lg border border-border bg-surface p-3"
|
||||||
|
data-state={state}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="art flex aspect-square w-full items-center justify-center overflow-hidden rounded-md bg-surface-hover"
|
||||||
|
>
|
||||||
|
{#if imageUrl}
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<FallbackIcon size={32} strokeWidth={1} class="text-text-muted" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text mt-3">
|
||||||
|
<div class="title text-base font-medium text-text-primary">{title}</div>
|
||||||
|
{#if subtitle}
|
||||||
|
<div class="subtitle text-sm text-text-secondary">{subtitle}</div>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="badge-row"
|
||||||
|
data-testid="badge-row"
|
||||||
|
style="min-height: 22px;"
|
||||||
|
>
|
||||||
|
{#if state === 'kept'}
|
||||||
|
<span class="kept-pill">Kept</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions pt-3" data-testid="actions" style="margin-top: auto;">
|
||||||
|
{#if state === 'requestable'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary"
|
||||||
|
onclick={handleRequest}
|
||||||
|
>
|
||||||
|
<Plus size={16} strokeWidth={1} /> Request
|
||||||
|
</button>
|
||||||
|
{:else if state === 'kept'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
onclick={handleRequest}
|
||||||
|
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted"
|
||||||
|
>
|
||||||
|
In library
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
onclick={handleRequest}
|
||||||
|
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted"
|
||||||
|
>
|
||||||
|
Requested
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.badge-row {
|
||||||
|
min-height: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
.kept-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 14px;
|
||||||
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||||
|
color: var(--fs-accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
|
import DiscoverResultCard from './DiscoverResultCard.svelte';
|
||||||
|
|
||||||
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
describe('DiscoverResultCard', () => {
|
||||||
|
test('requestable state renders Request button and calls onRequest on click', async () => {
|
||||||
|
const onRequest = vi.fn();
|
||||||
|
render(DiscoverResultCard, {
|
||||||
|
props: {
|
||||||
|
kind: 'artist',
|
||||||
|
title: 'Boards of Canada',
|
||||||
|
state: 'requestable',
|
||||||
|
onRequest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const btn = screen.getByRole('button', { name: /request/i });
|
||||||
|
expect(btn).not.toBeDisabled();
|
||||||
|
await fireEvent.click(btn);
|
||||||
|
expect(onRequest).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kept state renders disabled "In library" button + Kept pill', () => {
|
||||||
|
render(DiscoverResultCard, {
|
||||||
|
props: {
|
||||||
|
kind: 'album',
|
||||||
|
title: 'Music Has The Right To Children',
|
||||||
|
state: 'kept',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('button', { name: /in library/i })).toBeDisabled();
|
||||||
|
expect(screen.getByText(/kept/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requested state renders disabled "Requested" button', () => {
|
||||||
|
render(DiscoverResultCard, {
|
||||||
|
props: {
|
||||||
|
kind: 'track',
|
||||||
|
title: 'Roygbiv',
|
||||||
|
state: 'requested',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('button', { name: /requested/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not call onRequest when state is not requestable', async () => {
|
||||||
|
const onRequest = vi.fn();
|
||||||
|
render(DiscoverResultCard, {
|
||||||
|
props: {
|
||||||
|
kind: 'album',
|
||||||
|
title: 'Geogaddi',
|
||||||
|
state: 'kept',
|
||||||
|
onRequest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Button is disabled — the test still verifies onRequest isn't called even
|
||||||
|
// if a click slips through (jsdom does not enforce :disabled at fireEvent level).
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
await fireEvent.click(btn);
|
||||||
|
expect(onRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('badge row reserves min-height: 22px even when empty', () => {
|
||||||
|
render(DiscoverResultCard, {
|
||||||
|
props: {
|
||||||
|
kind: 'artist',
|
||||||
|
title: 'Aphex Twin',
|
||||||
|
state: 'requestable',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const row = screen.getByTestId('badge-row');
|
||||||
|
const cs = getComputedStyle(row);
|
||||||
|
expect(cs.minHeight).toBe('22px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('actions block is anchored to bottom (margin-top: auto)', () => {
|
||||||
|
render(DiscoverResultCard, {
|
||||||
|
props: {
|
||||||
|
kind: 'artist',
|
||||||
|
title: 'Aphex Twin',
|
||||||
|
state: 'requestable',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const actions = screen.getByTestId('actions');
|
||||||
|
expect(getComputedStyle(actions).marginTop).toBe('auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders <img> when imageUrl is set', () => {
|
||||||
|
const { container } = render(DiscoverResultCard, {
|
||||||
|
props: {
|
||||||
|
kind: 'artist',
|
||||||
|
title: 'X',
|
||||||
|
state: 'requestable',
|
||||||
|
imageUrl: 'https://example.com/x.jpg',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const img = container.querySelector('img') as HTMLImageElement;
|
||||||
|
expect(img).toBeInTheDocument();
|
||||||
|
expect(img.src).toBe('https://example.com/x.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders fallback Lucide glyph (no <img>) when imageUrl is absent', () => {
|
||||||
|
const { container } = render(DiscoverResultCard, {
|
||||||
|
props: {
|
||||||
|
kind: 'album',
|
||||||
|
title: 'X',
|
||||||
|
state: 'requestable',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(container.querySelector('img')).not.toBeInTheDocument();
|
||||||
|
// Lucide renders an inline <svg>; verify its presence as a proxy for "fallback rendered"
|
||||||
|
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user