Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion frontend/src/pages/Fields/components/FieldCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
* - overflow:hidden so the bleed-to-edge thumb clips against the radius
* - the lift + shadow hover transition
* - the descendant rule that fades the hover CTA in on hover
* - a token-driven focus-visible ring for keyboard users (Phase 2b)
*
* The card receives keyboard focus via tabIndex={0} on the React side;
* Phase 2b also wires Enter/Space activation. The :focus-visible rule
* below intentionally does NOT trigger on mouse-driven focus, matching
* the platform-default focus model.
*/
.card {
overflow: hidden;
Expand All @@ -21,7 +27,15 @@
box-shadow: var(--shadow-md);
}

.card:hover .hoverOverlay { opacity: 1; }
.card:hover .hoverOverlay,
.card:focus-visible .hoverOverlay { opacity: 1; }

.card:focus { outline: none; }

.card:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}

/* Thumbnail */
.thumb {
Expand Down
52 changes: 41 additions & 11 deletions frontend/src/pages/Fields/components/FieldCard.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { KeyboardEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowRight } from 'lucide-react';
import type { FieldDto } from '../../../types/field';
Expand All @@ -21,13 +22,18 @@ interface Props {
* internal layout — a thumb that bleeds to the edges plus an info
* block with its own padding. The local CSS module retains only the
* tile-specific affordances (cursor, hover lift + shadow, the
* absolutely-positioned hover overlay, and the inner thumb/info/
* badge/pill rules).
* absolutely-positioned hover overlay, the focus-visible ring and
* the inner thumb/info/badge/pill rules).
*
* Behavior preserved verbatim from the legacy implementation,
* including the existing `onClick` on a non-button element (an
* accessibility debt that is intentionally out of scope for this
* pure-surface migration).
* Phase 2b: pays down the accessibility debt left over from 2a.
* The clickable wrapper now exposes `role="button"`, `tabIndex={0}`
* and an explicit `onKeyDown` handler that activates on Enter and
* Space (matching native button semantics). An `aria-label` summarises
* the tile's primary content for assistive tech. The hover overlay's
* inner CTA was a real `<button>` nested inside a `role="button"`
* surface — invalid markup — and is now a non-interactive `<span>`
* styled identically (the parent surface is the activation target).
* Card / Surface API is unchanged; this is a pure FieldCard fix.
*/
export default function FieldCard({ field }: Props) {
const navigate = useNavigate();
Expand All @@ -43,14 +49,36 @@ export default function FieldCard({ field }: Props) {
2: t.fields.ownershipShareLease,
};

const goToField = () => navigate(`/fields/${field.id}`);
const handleKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goToField();
}
};

// Compact, screen-reader-friendly summary of the tile's primary content
// — name, area, and (when known) the current crop. Mirrors what a sighted
// user reads in the top-left of the card without duplicating the entire
// ownership / cadastral block. The `га` unit is used as a literal here to
// match the existing on-screen badge (`<span>{areaHectares.toFixed(1)} га</span>`)
// — i18n.fields has no `areaUnit` key and adding one is out of scope.
const ariaLabel = cropLabel
? `${field.name}, ${field.areaHectares.toFixed(1)} га, ${cropLabel}`
: `${field.name}, ${field.areaHectares.toFixed(1)} га`;

return (
<Card
as="div"
radius="lg"
p="0"
gap="0"
className={s.card}
onClick={() => navigate(`/fields/${field.id}`)}
role="button"
tabIndex={0}
aria-label={ariaLabel}
onClick={goToField}
onKeyDown={handleKeyDown}
>
{/* Polygon preview placeholder */}
<div className={s.thumb}>
Expand Down Expand Up @@ -86,11 +114,13 @@ export default function FieldCard({ field }: Props) {
</div>
</div>

{/* Hover CTA */}
<div className={s.hoverOverlay}>
<button className={s.viewBtn}>
{/* Hover CTA — purely decorative (the parent Card is the
activation target). aria-hidden keeps it out of the AT tree
since the card's aria-label already describes the action. */}
<div className={s.hoverOverlay} aria-hidden="true">
<span className={s.viewBtn}>
{t.fields.details} <ArrowRight size={12} />
</button>
</span>
</div>
</Card>
);
Expand Down
79 changes: 77 additions & 2 deletions frontend/src/pages/Fields/components/__tests__/FieldCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ describe('FieldCard — primary render', () => {
screen.queryByText('7120884600:01:001:0042'),
).not.toBeInTheDocument();
});

it('renders the hover-CTA label inside an aria-hidden decorative overlay', () => {
renderCard();

// The CTA is no longer a real <button>; it is a presentational <span>
// inside an aria-hidden overlay. We assert the visible label is present
// but is NOT exposed to assistive tech as an interactive control.
expect(screen.getByText('Деталі')).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Деталі' }),
).not.toBeInTheDocument();
});
});

describe('FieldCard — empty crop fallback', () => {
Expand All @@ -86,13 +98,76 @@ describe('FieldCard — empty crop fallback', () => {
});
});

describe('FieldCard — navigation', () => {
/* ── A11y (Phase 2b) ───────────────────────────────────────────────── */

describe('FieldCard — accessibility', () => {
it('exposes the card as a button with a descriptive accessible name', () => {
renderCard();

const card = screen.getByRole('button', { name: /Південне поле/ });
expect(card).toBeInTheDocument();
// Accessible name should summarise name + area + crop for AT users.
expect(card).toHaveAttribute(
'aria-label',
'Південне поле, 12.3 га, Пшениця',
);
});

it('exposes a descriptive accessible name even when no crop is set', () => {
renderCard({ currentCrop: undefined });

const card = screen.getByRole('button', { name: /Південне поле/ });
expect(card).toHaveAttribute('aria-label', 'Південне поле, 12.3 га');
});

it('is reachable via keyboard (tabIndex=0)', () => {
renderCard();

const card = screen.getByRole('button', { name: /Південне поле/ });
expect(card).toHaveAttribute('tabindex', '0');
});
});

/* ── Activation (mouse + keyboard) ─────────────────────────────────── */

describe('FieldCard — activation', () => {
it('navigates to /fields/{id} when the card surface is clicked', () => {
renderCard();

fireEvent.click(screen.getByText('Південне поле'));
fireEvent.click(screen.getByRole('button', { name: /Південне поле/ }));

expect(navigateMock).toHaveBeenCalledTimes(1);
expect(navigateMock).toHaveBeenCalledWith('/fields/field-42');
});

it('navigates when Enter is pressed while the card has focus', () => {
renderCard();
const card = screen.getByRole('button', { name: /Південне поле/ });

fireEvent.keyDown(card, { key: 'Enter' });

expect(navigateMock).toHaveBeenCalledTimes(1);
expect(navigateMock).toHaveBeenCalledWith('/fields/field-42');
});

it('navigates when Space is pressed while the card has focus', () => {
renderCard();
const card = screen.getByRole('button', { name: /Південне поле/ });

fireEvent.keyDown(card, { key: ' ' });

expect(navigateMock).toHaveBeenCalledTimes(1);
expect(navigateMock).toHaveBeenCalledWith('/fields/field-42');
});

it('does not navigate on unrelated keys (e.g. Tab, Escape, Shift)', () => {
renderCard();
const card = screen.getByRole('button', { name: /Південне поле/ });

fireEvent.keyDown(card, { key: 'Tab' });
fireEvent.keyDown(card, { key: 'Escape' });
fireEvent.keyDown(card, { key: 'Shift' });

expect(navigateMock).not.toHaveBeenCalled();
});
});
Loading