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",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/svelte-query": "^5.90.2"
|
||||
"@tanstack/svelte-query": "^5.90.2",
|
||||
"lucide-svelte": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
@@ -2637,6 +2638,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
|
||||
+2
-1
@@ -28,6 +28,7 @@
|
||||
"vitest": "^2.1.4"
|
||||
},
|
||||
"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