21c85a8b88
Vier der fuenf noch offenen Regeln aus Teillieferung B bereinigt:
- noRedundantRoles (4): ueberfluessige role-Angaben auf button/time/select
entfernt (maschineller --unsafe-Fix, gelesen).
- useAriaPropsForRole (1): entfaellt automatisch mit obigem Fix -- das
<select role="combobox"> in search-widget.tsx verlangte die fehlenden
ARIA-Attribute nur wegen der ueberfluessigen Rolle.
- useSemanticElements (4): admin-sidebar/settings-sidebar tragen role=
"navigation" jetzt am <nav> statt am <aside> (kein doppeltes Landmark
mehr); widget-wrapper.tsx ist jetzt ein echtes <article> statt
div role="article"; DropZone.tsx trennt die Datei-Entfernen-Schaltflaeche
als Geschwister ab, damit die Drop-Flaeche selbst ein echtes <button>
werden kann (ein <button> darf kein zweites <button> verschachteln).
Die Drop-Flaeche traegt darum jetzt Klick- UND Drag-Handler direkt am
<button>, sonst waere sie ein "statisches" Element mit Ereignis-Handlern
geworden (die zurueckgestellten Regeln noStaticElementInteractions /
noNoninteractiveElementInteractions waeren neu angeschlagen -- geprueft,
bleiben bei 5/11).
- noNoninteractiveTabindex (1): calculator-widget.tsx traegt jetzt
tabIndex={-1} statt {0}. Die Zifferntasten sind bereits echte <button>
und damit selbst Teil der Tab-Reihenfolge; Tastendruecke erreichen
handleKeyboard weiterhin per Bubbling, sobald eine Taste fokussiert ist.
Verhalten unveraendert, nur ein wirkungsloser Tab-Stopp auf dem Container
selbst entfaellt.
Verbleibend: noLabelWithoutControl (22), naechster Schritt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
139 lines
4.3 KiB
TypeScript
139 lines
4.3 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import type { WidgetProps } from '../widget-registry';
|
|
import { fetchSearchProviders, updateWidgetConfig } from '@/lib/dashboard-api';
|
|
|
|
/**
|
|
* Default hardcoded search providers (D-15).
|
|
* Used as fallback if the API fetch fails so the widget always works.
|
|
*/
|
|
const DEFAULT_PROVIDERS = [
|
|
{ id: 'google', name: 'Google', urlTemplate: 'https://www.google.com/search?q={query}', isDefault: true },
|
|
{ id: 'bing', name: 'Bing', urlTemplate: 'https://www.bing.com/search?q={query}', isDefault: true },
|
|
{ id: 'ddg', name: 'DuckDuckGo', urlTemplate: 'https://duckduckgo.com/?q={query}', isDefault: true },
|
|
];
|
|
|
|
export interface SearchProvider {
|
|
id: string;
|
|
name: string;
|
|
urlTemplate: string;
|
|
isDefault: boolean;
|
|
}
|
|
|
|
/**
|
|
* Search widget (DASH-04, D-14/D-15).
|
|
* Horizontal layout: provider dropdown left, text input center, search button right.
|
|
* Opens web search in a new tab via window.open with noopener,noreferrer.
|
|
*/
|
|
export function SearchWidget({ instanceId, config }: WidgetProps) {
|
|
const t = useTranslations('widgets');
|
|
const [providers, setProviders] = useState<SearchProvider[]>(DEFAULT_PROVIDERS);
|
|
const [selectedProviderId, setSelectedProviderId] = useState<string>(
|
|
(config.providerId as string) || 'google',
|
|
);
|
|
const [query, setQuery] = useState('');
|
|
|
|
// Fetch providers from API, fallback to defaults on error
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
fetchSearchProviders()
|
|
.then((data) => {
|
|
if (!cancelled && data.length > 0) {
|
|
setProviders(data);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Keep default providers — widget always works
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
// Persist selected provider to widget config
|
|
const handleProviderChange = useCallback(
|
|
(providerId: string) => {
|
|
setSelectedProviderId(providerId);
|
|
updateWidgetConfig(instanceId, { providerId }).catch(() => {
|
|
// Silently fail — provider selection is non-critical
|
|
});
|
|
},
|
|
[instanceId],
|
|
);
|
|
|
|
const handleSearch = useCallback(() => {
|
|
if (!query.trim()) return;
|
|
|
|
const provider = providers.find((p) => p.id === selectedProviderId) ?? providers[0];
|
|
if (!provider) return;
|
|
|
|
const url = provider.urlTemplate.replace(
|
|
'{query}',
|
|
encodeURIComponent(query.trim()),
|
|
);
|
|
window.open(url, '_blank', 'noopener,noreferrer');
|
|
}, [query, selectedProviderId, providers]);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') {
|
|
handleSearch();
|
|
}
|
|
},
|
|
[handleSearch],
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-full items-center gap-2 px-1.5">
|
|
{/* Provider dropdown (D-14: ~120px left) */}
|
|
<select
|
|
className="h-8 w-[120px] shrink-0 rounded border border-border bg-card px-2 text-sm text-foreground"
|
|
value={selectedProviderId}
|
|
onChange={(e) => handleProviderChange(e.target.value)}
|
|
>
|
|
{providers.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Search input (flex-1 center) */}
|
|
<input
|
|
type="text"
|
|
className="h-8 flex-1 rounded border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
placeholder={t('search.placeholder')}
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
/>
|
|
|
|
{/* Search button (right) */}
|
|
<button
|
|
type="button"
|
|
className="flex h-8 shrink-0 items-center justify-center rounded bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
|
onClick={handleSearch}
|
|
aria-label={t('search.searchButton')}
|
|
>
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<circle cx="11" cy="11" r="8" />
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|