feat(13-03): add icon prop to BackendCard and category/search to BackendSelectionStep

- BackendCard accepts optional icon prop rendered in flex row with name
- BackendSelectionStep groups 18 backends into 3 category sections
- Search bar (type=search/searchbox role) filters across displayName, description, category label, field labels
- Empty categories hidden via conditional rendering
- BACKEND_ICONS wired to BackendCard icon prop
- Changed search input to type=search to preserve getByRole('textbox') selector in existing tests
This commit is contained in:
2026-04-01 16:50:49 +02:00
parent 683faf6f11
commit 0a70620fd5
2 changed files with 66 additions and 15 deletions
+10 -2
View File
@@ -1,13 +1,14 @@
import type { ButtonHTMLAttributes } from 'react';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
interface BackendCardProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
name: string;
description: string;
selected?: boolean;
onClick: () => void;
icon?: ReactNode;
}
export function BackendCard({ name, description, selected = false, onClick, ...rest }: BackendCardProps) {
export function BackendCard({ name, description, selected = false, onClick, icon, ...rest }: BackendCardProps) {
return (
<button
type="button"
@@ -21,7 +22,14 @@ export function BackendCard({ name, description, selected = false, onClick, ...r
].join(' ')}
{...rest}
>
{icon != null ? (
<div className="flex items-center gap-2">
<span className="w-6 h-6 shrink-0">{icon}</span>
<span className="font-semibold text-on-surface">{name}</span>
</div>
) : (
<span className="font-semibold text-on-surface">{name}</span>
)}
<span className="text-sm text-on-surface-container/70">{description}</span>
</button>
);
+47 -4
View File
@@ -1,14 +1,16 @@
import { useRef } from 'react';
import { useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import React from 'react';
import { useWizard } from '../../store/context';
import type { BackendType } from '../../store/types';
import type { BackendType, BackendCategory } from '../../schemas/registry';
import { BACKEND_REGISTRY } from '../../schemas/registry';
import { BackendCard } from '../ui/BackendCard';
import { MD3_BTN_FILLED } from '../../styles/md3-buttons';
import { TextFieldMD3 } from '../ui/TextFieldMD3';
import { RemoteNamePreview } from '../ui/RemoteNamePreview';
import { BACKEND_ICONS } from '../icons/BackendIcons';
const remoteNameSchema = z.object({
name: z
@@ -22,10 +24,30 @@ const remoteNameSchema = z.object({
type RemoteNameFormValues = z.infer<typeof remoteNameSchema>;
const CATEGORY_ORDER: BackendCategory[] = ['cloud-object-storage', 'cloud-drives', 'protocol-based'];
const CATEGORY_LABELS: Record<BackendCategory, string> = {
'cloud-object-storage': 'Cloud Object Storage',
'cloud-drives': 'Cloud Drives',
'protocol-based': 'Protocol-based',
};
function matchesSearch(
entry: { displayName: string; description: string; category: BackendCategory; fields: { label: string }[] },
query: string
): boolean {
const q = query.toLowerCase();
return (
entry.displayName.toLowerCase().includes(q) ||
entry.description.toLowerCase().includes(q) ||
CATEGORY_LABELS[entry.category].toLowerCase().includes(q) ||
entry.fields.some(f => f.label.toLowerCase().includes(q))
);
}
export function BackendSelectionStep() {
const { state, dispatch } = useWizard();
const pendingBackend = useRef<BackendType | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const {
register,
@@ -77,17 +99,38 @@ export function BackendSelectionStep() {
helpText="This becomes the section header [name] in your rclone.conf. Example: azure-prod, backup-s3. Letters, numbers, dashes, underscores only."
/>
<RemoteNamePreview value={remoteName} />
<div data-testid="backend-cards" className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-4">
{Object.entries(BACKEND_REGISTRY).map(([type, entry]) => (
<input
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search backends..."
className="w-full px-4 py-2.5 rounded-xl border border-outline bg-surface-container text-on-surface placeholder:text-on-surface-variant/50 focus:outline-none focus:ring-2 focus:ring-primary mt-4"
/>
<div data-testid="backend-cards" className="mt-4">
{CATEGORY_ORDER.map(cat => {
const backends = Object.entries(BACKEND_REGISTRY)
.filter(([, e]) => e.category === cat)
.filter(([, e]) => !searchQuery || matchesSearch(e, searchQuery));
if (backends.length === 0) return null;
return (
<section key={cat} className="mb-6">
<h3 className="text-lg font-semibold text-on-surface mb-3">{CATEGORY_LABELS[cat]}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{backends.map(([type, entry]) => (
<BackendCard
key={type}
name={entry.displayName}
description={entry.description}
selected={state.remote.backendType === type}
onClick={() => handleCardClick(type as BackendType)}
icon={BACKEND_ICONS[type as BackendType] ? React.createElement(BACKEND_ICONS[type as BackendType]!, { className: 'w-6 h-6' }) : undefined}
/>
))}
</div>
</section>
);
})}
</div>
<div className="flex flex-col sm:flex-row gap-3 mt-6">
<button type="submit" className={`${MD3_BTN_FILLED} w-full sm:w-auto`}>
Next