Compare commits

...

2 Commits

Author SHA1 Message Date
logaritmisk 091a1a651d merge: focus-visible rings + live search count (#69)
CI / web (push) Successful in 5m7s
2026-06-10 13:31:15 +02:00
logaritmisk ec11c9dc76 fix(web): focus-visible rings on remaining controls + live search count (#69)
Keyboard focus was invisible on the objects-table sort headers and
page-size select, breadcrumb links, the external-URI link, and the
combobox input/clear/trigger. Apply the shared focusRing helper in app
code and the kit's inline focus-visible classes (matching input.tsx)
in ui/combobox.

Make the search result count a role="status" live region so screen
readers announce updated counts while typing; the existing search test
now asserts the count through getByRole("status").

Closes #69

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:29:27 +02:00
6 changed files with 22 additions and 9 deletions
+3 -1
View File
@@ -1,10 +1,12 @@
import { focusRing } from "../lib/focus-ring";
export function ExternalUriLink({ uri }: { uri: string }) { export function ExternalUriLink({ uri }: { uri: string }) {
return ( return (
<a <a
href={uri} href={uri}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="block truncate text-xs text-muted-foreground hover:text-foreground" className={`block truncate rounded-sm text-xs text-muted-foreground hover:text-foreground ${focusRing}`}
> >
{uri} {uri}
</a> </a>
+9 -3
View File
@@ -20,7 +20,10 @@ function ComboboxInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
return ( return (
<ComboboxPrimitive.Input <ComboboxPrimitive.Input
data-slot="combobox-input" data-slot="combobox-input"
className={cn("w-full rounded border px-2 py-1 pr-12 text-sm", className)} className={cn(
"w-full rounded border px-2 py-1 pr-12 text-sm transition-colors outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
className,
)}
{...props} {...props}
/> />
); );
@@ -31,7 +34,7 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
<ComboboxPrimitive.Clear <ComboboxPrimitive.Clear
data-slot="combobox-clear" data-slot="combobox-clear"
className={cn( className={cn(
"absolute right-6 text-muted-foreground hover:text-foreground", "absolute right-6 rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50",
className, className,
)} )}
{...props} {...props}
@@ -43,7 +46,10 @@ function ComboboxTrigger({ className, ...props }: ComboboxPrimitive.Trigger.Prop
return ( return (
<ComboboxPrimitive.Trigger <ComboboxPrimitive.Trigger
data-slot="combobox-trigger" data-slot="combobox-trigger"
className={cn("absolute right-1 text-muted-foreground", className)} className={cn(
"absolute right-1 rounded-sm text-muted-foreground outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
className,
)}
{...props} {...props}
/> />
); );
+2 -2
View File
@@ -131,7 +131,7 @@ export function ObjectsTable() {
<button <button
type="button" type="button"
onClick={() => toggleSort(col)} onClick={() => toggleSort(col)}
className="flex items-center gap-1 hover:text-foreground" className={`flex items-center gap-1 rounded-sm hover:text-foreground ${focusRing}`}
> >
{t(COLUMN_KEYS[col])} {t(COLUMN_KEYS[col])}
<Icon className="size-3.5 text-muted-foreground" aria-hidden="true" /> <Icon className="size-3.5 text-muted-foreground" aria-hidden="true" />
@@ -287,7 +287,7 @@ export function ObjectsTable() {
value={limit} value={limit}
onChange={(event) => setLimit(Number(event.target.value))} onChange={(event) => setLimit(Number(event.target.value))}
aria-label={t("objects.pageSize")} aria-label={t("objects.pageSize")}
className="rounded-md border bg-background px-1 py-0.5" className={`rounded-md border bg-background px-1 py-0.5 ${focusRing}`}
> >
{PAGE_SIZES.map((size) => ( {PAGE_SIZES.map((size) => (
<option key={size} value={size}> <option key={size} value={size}>
+1 -1
View File
@@ -102,7 +102,7 @@ export function SearchPanel() {
{hits.length > 0 && ( {hits.length > 0 && (
<> <>
<p className="px-3 pt-2 text-xs text-muted-foreground"> <p role="status" className="px-3 pt-2 text-xs text-muted-foreground">
{t("search.resultCount", { count: total })} {t("search.resultCount", { count: total })}
</p> </p>
<ul> <ul>
+2 -1
View File
@@ -60,7 +60,8 @@ test("typing searches and renders highlighted rich rows", async () => {
expect(await screen.findByText("Bronze figurine")).toBeInTheDocument(); expect(await screen.findByText("Bronze figurine")).toBeInTheDocument();
const mark = await screen.findByText("bronze"); const mark = await screen.findByText("bronze");
expect(mark.tagName).toBe("MARK"); expect(mark.tagName).toBe("MARK");
expect(screen.getByText(/~\s*25 results/i)).toBeInTheDocument(); // The estimated count lives in a status region so updates are announced.
expect(screen.getByRole("status")).toHaveTextContent(/~\s*25 results/i);
expect(screen.getByText(/1962-04-03/)).toBeInTheDocument(); expect(screen.getByText(/1962-04-03/)).toBeInTheDocument();
}); });
+5 -1
View File
@@ -2,6 +2,7 @@ import { Fragment } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { focusRing } from "../lib/focus-ring";
import { useBreadcrumbTrail } from "./breadcrumb-context"; import { useBreadcrumbTrail } from "./breadcrumb-context";
export function Breadcrumb() { export function Breadcrumb() {
@@ -16,7 +17,10 @@ export function Breadcrumb() {
<Fragment key={`${item.label}-${i}`}> <Fragment key={`${item.label}-${i}`}>
{i > 0 && <span className="text-muted-foreground">/</span>} {i > 0 && <span className="text-muted-foreground">/</span>}
{item.to && !last ? ( {item.to && !last ? (
<Link to={item.to} className="truncate text-muted-foreground hover:text-foreground"> <Link
to={item.to}
className={`truncate rounded-sm text-muted-foreground hover:text-foreground ${focusRing}`}
>
{item.label} {item.label}
</Link> </Link>
) : ( ) : (