feat(form): add compact option groups

- add a size prop to OptionButtonGroup with a denser sm layout\n- use the compact filter group on the auto-import panel\n- keep the new size variants covered in form and route tests
This commit is contained in:
Antti Kettunen 2026-05-18 20:50:28 +03:00
parent fccc03efef
commit 9ba54bd82d
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
5 changed files with 105 additions and 53 deletions

View file

@ -396,6 +396,17 @@
gap: 8px; gap: 8px;
} }
.optionButtonGroupSizeSm {
gap: 6px;
}
.optionButtonGroupSizeSm .optionButton {
min-width: 0;
padding: 6px 12px;
font-size: 12px;
gap: 6px;
}
.optionButton { .optionButton {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View file

@ -178,6 +178,17 @@ describe('form primitives', () => {
); );
}); });
it('supports compact option button groups', () => {
const { container } = render(
<OptionButtonGroup size="sm">
<OptionButton selected>All</OptionButton>
<OptionButton>Pending</OptionButton>
</OptionButtonGroup>,
);
expect(container.querySelector('[data-size="sm"]')).toBeInTheDocument();
});
it('supports compact select sizing', () => { it('supports compact select sizing', () => {
render( render(
<Select aria-label="Compact" defaultValue="one" size="sm"> <Select aria-label="Compact" defaultValue="one" size="sm">

View file

@ -247,14 +247,33 @@ export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(functio
); );
}); });
export type OptionButtonGroupSize = 'sm' | 'md';
export interface OptionButtonGroupProps {
children: ReactNode;
className?: string;
size?: OptionButtonGroupSize;
}
export function OptionButtonGroup({ export function OptionButtonGroup({
className, className,
children, children,
}: { size = 'md',
children: ReactNode; }: OptionButtonGroupProps) {
className?: string; return (
}) { <div
return <div className={clsx(styles.optionButtonGroup, className)}>{children}</div>; className={clsx(
styles.optionButtonGroup,
{
[styles.optionButtonGroupSizeSm]: size === 'sm',
},
className,
)}
data-size={size}
>
{children}
</div>
);
} }
export interface OptionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value'> { export interface OptionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value'> {

View file

@ -304,6 +304,8 @@ describe('import route', () => {
expect(await screen.findByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument();
expect(screen.getAllByText('Album A').length).toBeGreaterThan(0); expect(screen.getAllByText('Album A').length).toBeGreaterThan(0);
expect(screen.getByText('Watching')).toHaveAttribute('data-tone', 'success'); expect(screen.getByText('Watching')).toHaveAttribute('data-tone', 'success');
const compactFilterGroup = document.querySelector('#auto-import-results [data-size="sm"]');
expect(compactFilterGroup).toBeInTheDocument();
const intervalSelect = document.getElementById('auto-import-interval'); const intervalSelect = document.getElementById('auto-import-interval');
if (!(intervalSelect instanceof HTMLElement)) { if (!(intervalSelect instanceof HTMLElement)) {
throw new Error('auto-import interval select missing'); throw new Error('auto-import interval select missing');

View file

@ -255,43 +255,45 @@ export function AutoImportPanel({
</div> </div>
{allResults.length > 0 ? ( {allResults.length > 0 ? (
<> <OptionButtonGroup size="sm" className={styles.autoImportFilters}>
<OptionButtonGroup className={styles.autoImportFilters}> {(['all', 'pending', 'imported', 'failed'] as const).map((filter) => (
{(['all', 'pending', 'imported', 'failed'] as const).map((filter) => ( <OptionButton
<OptionButton key={filter} selected={autoFilter === filter} onClick={() => onFilterChange(filter)}> key={filter}
<span>{getAutoImportFilterLabel(filter)}</span> selected={autoFilter === filter}
<Badge tone={getAutoImportFilterTone(filter)}> onClick={() => onFilterChange(filter)}
{getAutoImportFilterCount(filter, counts, allResults.length)} >
</Badge> <span>{getAutoImportFilterLabel(filter)}</span>
</OptionButton> <Badge tone={getAutoImportFilterTone(filter)}>
))} {getAutoImportFilterCount(filter, counts, allResults.length)}
<div className={styles.importPageFlexSpacer} /> </Badge>
{counts.review > 0 ? ( </OptionButton>
<Button ))}
type="button" <div className={styles.importPageFlexSpacer} />
variant="secondary" {counts.review > 0 ? (
size="sm" <Button
id="auto-import-approve-all" type="button"
disabled={approveAllMutation.isPending} variant="secondary"
onClick={() => approveAllMutation.mutate()} size="sm"
> id="auto-import-approve-all"
Approve All disabled={approveAllMutation.isPending}
</Button> onClick={() => approveAllMutation.mutate()}
) : null} >
{counts.imported + counts.failed > 0 ? ( Approve All
<Button </Button>
type="button" ) : null}
variant="ghost" {counts.imported + counts.failed > 0 ? (
size="sm" <Button
id="auto-import-clear-completed" type="button"
disabled={clearMutation.isPending} variant="ghost"
onClick={() => clearMutation.mutate()} size="sm"
> id="auto-import-clear-completed"
Clear History disabled={clearMutation.isPending}
</Button> onClick={() => clearMutation.mutate()}
) : null} >
</OptionButtonGroup> Clear History
</> </Button>
) : null}
</OptionButtonGroup>
) : null} ) : null}
<div className={styles.autoImportResults} id="auto-import-results"> <div className={styles.autoImportResults} id="auto-import-results">
@ -354,8 +356,8 @@ function AutoImportResultCard({
status: ImportAutoImportStatusPayload | undefined; status: ImportAutoImportStatusPayload | undefined;
onApprove: () => void; onApprove: () => void;
onReject: () => void; onReject: () => void;
onToggle: () => void; onToggle: () => void;
}) { }) {
const confidencePercent = Math.round((result.confidence || 0) * 100); const confidencePercent = Math.round((result.confidence || 0) * 100);
const confidenceClass = getConfidenceClass(confidencePercent); const confidenceClass = getConfidenceClass(confidencePercent);
const statusMeta = getAutoImportStatusMeta(result.status); const statusMeta = getAutoImportStatusMeta(result.status);
@ -412,11 +414,17 @@ function AutoImportResultCard({
)} )}
</div> </div>
<div className={styles.autoImportCardCenter}> <div className={styles.autoImportCardCenter}>
<div className={styles.autoImportCardAlbum}>{result.album_name || result.folder_name}</div> <div className={styles.autoImportCardAlbum}>
<div className={styles.autoImportCardArtist}>{result.artist_name || 'Unknown Artist'}</div> {result.album_name || result.folder_name}
</div>
<div className={styles.autoImportCardArtist}>
{result.artist_name || 'Unknown Artist'}
</div>
<div className={styles.autoImportCardMeta}> <div className={styles.autoImportCardMeta}>
<span>{matchSummary}</span> <span>{matchSummary}</span>
{methodLabel ? <span className={styles.autoImportMethodBadge}>{methodLabel}</span> : null} {methodLabel ? (
<span className={styles.autoImportMethodBadge}>{methodLabel}</span>
) : null}
{timeAgo ? <span>{timeAgo}</span> : null} {timeAgo ? <span>{timeAgo}</span> : null}
</div> </div>
{result.error_message ? ( {result.error_message ? (
@ -488,13 +496,12 @@ function AutoImportResultCard({
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
return ( return (
<div <div key={`${track.name}-${track.file}-${trackIndex}`} className={rowClassName}>
key={`${track.name}-${track.file}-${trackIndex}`}
className={rowClassName}
>
<span className={styles.autoImportTrackName}>{track.name}</span> <span className={styles.autoImportTrackName}>{track.name}</span>
<span className={styles.autoImportTrackFile}>{track.file}</span> <span className={styles.autoImportTrackFile}>{track.file}</span>
<span className={`${styles.autoImportTrackConf} ${getAutoImportConfidenceClass(getConfidenceClass(track.confidence))}`}> <span
className={`${styles.autoImportTrackConf} ${getAutoImportConfidenceClass(getConfidenceClass(track.confidence))}`}
>
{track.confidence}% {track.confidence}%
</span> </span>
</div> </div>
@ -573,7 +580,9 @@ function getAutoImportFilterCount(
} }
} }
function getAutoImportFilterTone(filter: ImportAutoFilter): 'neutral' | 'warning' | 'success' | 'danger' { function getAutoImportFilterTone(
filter: ImportAutoFilter,
): 'neutral' | 'warning' | 'success' | 'danger' {
switch (filter) { switch (filter) {
case 'pending': case 'pending':
return 'warning'; return 'warning';