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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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'> {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue