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:
parent
fccc03efef
commit
9ba54bd82d
5 changed files with 105 additions and 53 deletions
|
|
@ -396,6 +396,17 @@
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.optionButtonGroupSizeSm {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.optionButtonGroupSizeSm .optionButton {
|
||||
min-width: 0;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.optionButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
render(
|
||||
<Select aria-label="Compact" defaultValue="one" size="sm">
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={clsx(styles.optionButtonGroup, className)}>{children}</div>;
|
||||
size = 'md',
|
||||
}: OptionButtonGroupProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.optionButtonGroup,
|
||||
{
|
||||
[styles.optionButtonGroupSizeSm]: size === 'sm',
|
||||
},
|
||||
className,
|
||||
)}
|
||||
data-size={size}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OptionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value'> {
|
||||
|
|
|
|||
|
|
@ -304,6 +304,8 @@ describe('import route', () => {
|
|||
expect(await screen.findByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Album A').length).toBeGreaterThan(0);
|
||||
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');
|
||||
if (!(intervalSelect instanceof HTMLElement)) {
|
||||
throw new Error('auto-import interval select missing');
|
||||
|
|
|
|||
|
|
@ -255,43 +255,45 @@ export function AutoImportPanel({
|
|||
</div>
|
||||
|
||||
{allResults.length > 0 ? (
|
||||
<>
|
||||
<OptionButtonGroup className={styles.autoImportFilters}>
|
||||
{(['all', 'pending', 'imported', 'failed'] as const).map((filter) => (
|
||||
<OptionButton key={filter} selected={autoFilter === filter} onClick={() => onFilterChange(filter)}>
|
||||
<span>{getAutoImportFilterLabel(filter)}</span>
|
||||
<Badge tone={getAutoImportFilterTone(filter)}>
|
||||
{getAutoImportFilterCount(filter, counts, allResults.length)}
|
||||
</Badge>
|
||||
</OptionButton>
|
||||
))}
|
||||
<div className={styles.importPageFlexSpacer} />
|
||||
{counts.review > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
id="auto-import-approve-all"
|
||||
disabled={approveAllMutation.isPending}
|
||||
onClick={() => approveAllMutation.mutate()}
|
||||
>
|
||||
Approve All
|
||||
</Button>
|
||||
) : null}
|
||||
{counts.imported + counts.failed > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
id="auto-import-clear-completed"
|
||||
disabled={clearMutation.isPending}
|
||||
onClick={() => clearMutation.mutate()}
|
||||
>
|
||||
Clear History
|
||||
</Button>
|
||||
) : null}
|
||||
</OptionButtonGroup>
|
||||
</>
|
||||
<OptionButtonGroup size="sm" className={styles.autoImportFilters}>
|
||||
{(['all', 'pending', 'imported', 'failed'] as const).map((filter) => (
|
||||
<OptionButton
|
||||
key={filter}
|
||||
selected={autoFilter === filter}
|
||||
onClick={() => onFilterChange(filter)}
|
||||
>
|
||||
<span>{getAutoImportFilterLabel(filter)}</span>
|
||||
<Badge tone={getAutoImportFilterTone(filter)}>
|
||||
{getAutoImportFilterCount(filter, counts, allResults.length)}
|
||||
</Badge>
|
||||
</OptionButton>
|
||||
))}
|
||||
<div className={styles.importPageFlexSpacer} />
|
||||
{counts.review > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
id="auto-import-approve-all"
|
||||
disabled={approveAllMutation.isPending}
|
||||
onClick={() => approveAllMutation.mutate()}
|
||||
>
|
||||
Approve All
|
||||
</Button>
|
||||
) : null}
|
||||
{counts.imported + counts.failed > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
id="auto-import-clear-completed"
|
||||
disabled={clearMutation.isPending}
|
||||
onClick={() => clearMutation.mutate()}
|
||||
>
|
||||
Clear History
|
||||
</Button>
|
||||
) : null}
|
||||
</OptionButtonGroup>
|
||||
) : null}
|
||||
|
||||
<div className={styles.autoImportResults} id="auto-import-results">
|
||||
|
|
@ -354,8 +356,8 @@ function AutoImportResultCard({
|
|||
status: ImportAutoImportStatusPayload | undefined;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const confidencePercent = Math.round((result.confidence || 0) * 100);
|
||||
const confidenceClass = getConfidenceClass(confidencePercent);
|
||||
const statusMeta = getAutoImportStatusMeta(result.status);
|
||||
|
|
@ -412,11 +414,17 @@ function AutoImportResultCard({
|
|||
)}
|
||||
</div>
|
||||
<div className={styles.autoImportCardCenter}>
|
||||
<div className={styles.autoImportCardAlbum}>{result.album_name || result.folder_name}</div>
|
||||
<div className={styles.autoImportCardArtist}>{result.artist_name || 'Unknown Artist'}</div>
|
||||
<div className={styles.autoImportCardAlbum}>
|
||||
{result.album_name || result.folder_name}
|
||||
</div>
|
||||
<div className={styles.autoImportCardArtist}>
|
||||
{result.artist_name || 'Unknown Artist'}
|
||||
</div>
|
||||
<div className={styles.autoImportCardMeta}>
|
||||
<span>{matchSummary}</span>
|
||||
{methodLabel ? <span className={styles.autoImportMethodBadge}>{methodLabel}</span> : null}
|
||||
{methodLabel ? (
|
||||
<span className={styles.autoImportMethodBadge}>{methodLabel}</span>
|
||||
) : null}
|
||||
{timeAgo ? <span>{timeAgo}</span> : null}
|
||||
</div>
|
||||
{result.error_message ? (
|
||||
|
|
@ -488,13 +496,12 @@ function AutoImportResultCard({
|
|||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return (
|
||||
<div
|
||||
key={`${track.name}-${track.file}-${trackIndex}`}
|
||||
className={rowClassName}
|
||||
>
|
||||
<div key={`${track.name}-${track.file}-${trackIndex}`} className={rowClassName}>
|
||||
<span className={styles.autoImportTrackName}>{track.name}</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}%
|
||||
</span>
|
||||
</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) {
|
||||
case 'pending':
|
||||
return 'warning';
|
||||
|
|
|
|||
Loading…
Reference in a new issue