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
18 changes: 18 additions & 0 deletions frontend/src/pages/Dashboard/components/FieldStatusCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,24 @@

.row:hover { background: var(--bg-hover); }

/*
* Phase 2f keyboard-focus affordance — mirrors the FieldCard /
* OperationsTimeline / UpcomingPanel pattern from Phases 2b / 2c / 2d:
*
* - .row:focus drops the platform-default outline so mouse focus
* stays ring-free.
* - .row:focus-visible draws a token-driven brand-coloured ring
* and pairs it with the same hover background, so keyboard and
* mouse focus look identical (preserves hover/focus parity).
*/
.row:focus { outline: none; }

.row:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
background: var(--bg-hover);
}

.rowLeft {
flex: 1;
min-width: 0;
Expand Down
68 changes: 65 additions & 3 deletions frontend/src/pages/Dashboard/components/FieldStatusCard.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, PlusCircle } from 'lucide-react';
import type { FieldDto } from '../../../types/field';
Expand All @@ -10,6 +11,43 @@ interface Props {
onAddField?: () => void;
}

/**
* FieldStatusCard — compact "fields snapshot" widget on the v1
* dashboard. The header CTA ("Усі поля") and the empty-state CTA
* ("Add field") are already real <button> elements, so the only
* accessibility debt was the per-row clickable wrapper:
*
* <div className={s.row} onClick={() => navigate('/fields')}>
*
* with `cursor: pointer` in the CSS module but no role, no tabIndex,
* no keyboard handler, no accessible name. Phase 2e flagged this as
* the **last plain `<div onClick>` row in the codebase** and a
* low-risk paydown identical to Phases 2b / 2d.
*
* Phase 2f applies that proven pattern verbatim:
* - role="button" + tabIndex={0} + onKeyDown (Enter, Space) on each
* row, with preventDefault on Space to suppress page scroll
* - aria-label summarising the row exactly as it reads visually,
* so screen readers can distinguish rows even though every row
* navigates to the same `/fields` destination
* - the decorative crop tag, area pill and area-bar are marked
* aria-hidden because their content is duplicated in aria-label
* (the bar is purely a visual indicator with no textual content
* of its own)
* - a token-driven :focus-visible ring (var(--brand), 2px, offset
* 2px) lives in the CSS module, paired with the existing hover
* background so keyboard and mouse focus look identical
*
* No nested <button> exists inside the row, so the FieldCard
* "decorative button → presentation span" step from Phase 2b does
* not apply here.
*
* Visual layout, the navigation target (/fields), the field
* filter / sort / slice(0, 6) logic, the empty-state behaviour
* including the optional `onAddField` prop, and every i18n string
* are preserved verbatim. The Card / Surface API is not touched,
* no dependencies are added, no routes change.
*/
export default function FieldStatusCard({ fields, onAddField }: Props) {
const navigate = useNavigate();
const { t } = useTranslation();
Expand Down Expand Up @@ -40,12 +78,35 @@ export default function FieldStatusCard({ fields, onAddField }: Props) {
const cropLabel = cropKey ? (t.crops[cropKey] || field.currentCrop) : t.fields.notSeeded;
const cropStyle = field.currentCrop ? getCropTagStyle(cropLabel ?? '') : undefined;
const areaPct = (field.areaHectares / maxArea) * 100;
const areaText = `${field.areaHectares.toFixed(1)} га`;

// Concise screen-reader summary mirroring the row's
// visible content. Each aria-label is field-specific even
// though every row navigates to the same /fields page —
// assistive tech users still need to tell rows apart.
const ariaLabel = `${field.name}, ${cropLabel}, ${areaText}`;

const goToFields = () => navigate('/fields');
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goToFields();
}
};

return (
<div key={field.id} className={s.row} onClick={() => navigate('/fields')}>
<div
key={field.id}
className={s.row}
role="button"
tabIndex={0}
aria-label={ariaLabel}
onClick={goToFields}
onKeyDown={handleKeyDown}
>
<div className={s.rowLeft}>
<span className={s.fieldName}>{field.name}</span>
<div className={s.bar}>
<div className={s.bar} aria-hidden="true">
<div
className={s.barFill}
style={{
Expand All @@ -59,10 +120,11 @@ export default function FieldStatusCard({ fields, onAddField }: Props) {
<span
className={s.cropTag}
style={cropStyle ? { background: cropStyle.background, color: cropStyle.color } : undefined}
aria-hidden="true"
>
{cropLabel}
</span>
<span className={s.area}>{field.areaHectares.toFixed(1)} га</span>
<span className={s.area} aria-hidden="true">{areaText}</span>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import FieldStatusCard from '../FieldStatusCard';
import type { FieldDto } from '../../../../types/field';

/* ── Mocks ─────────────────────────────────────────────────────────── */

const navigateMock = vi.fn();
vi.mock('react-router-dom', async () => {
const actual =
await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useNavigate: () => navigateMock,
};
});

vi.mock('../../../../i18n', () => ({
useTranslation: () => ({
t: {
dashboard: {
fieldsStatus: 'Стан полів',
getStarted: 'Почніть роботу',
addFirstField: 'Додайте перше поле',
},
fields: {
addField: 'Додати поле',
notSeeded: 'Не засіяно',
},
crops: {
Wheat: 'Пшениця',
Corn: 'Кукурудза',
},
},
lang: 'uk',
setLang: vi.fn(),
}),
}));

/* ── Helpers ───────────────────────────────────────────────────────── */

const makeField = (overrides: Partial<FieldDto> = {}): FieldDto =>
({
id: 'f-1',
name: 'Південне поле',
areaHectares: 12.5,
currentCrop: 'Wheat',
...overrides,
} as FieldDto);

const renderCard = (fields: FieldDto[], onAddField?: () => void) =>
render(
<MemoryRouter>
<FieldStatusCard fields={fields} onAddField={onAddField} />
</MemoryRouter>,
);

beforeEach(() => navigateMock.mockReset());

/* ── Empty state ───────────────────────────────────────────────────── */

describe('FieldStatusCard — empty state', () => {
it('renders the getStarted placeholder when no fields are supplied', () => {
renderCard([]);
expect(screen.getByText('Почніть роботу')).toBeInTheDocument();
expect(screen.getByText('Додайте перше поле')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Додати поле/ })).toBeInTheDocument();
});

it('addField button calls the onAddField prop when supplied', () => {
const onAdd = vi.fn();
renderCard([], onAdd);
fireEvent.click(screen.getByRole('button', { name: /Додати поле/ }));
expect(onAdd).toHaveBeenCalledTimes(1);
expect(navigateMock).not.toHaveBeenCalled();
});

it('addField button navigates to /fields when onAddField is omitted', () => {
renderCard([]);
fireEvent.click(screen.getByRole('button', { name: /Додати поле/ }));
expect(navigateMock).toHaveBeenCalledTimes(1);
expect(navigateMock).toHaveBeenCalledWith('/fields');
});
});

/* ── Primary render ────────────────────────────────────────────────── */

describe('FieldStatusCard — primary render', () => {
it('renders one row per field with name, crop label and area text', () => {
renderCard([
makeField(),
makeField({ id: 'f-2', name: 'Західне поле', currentCrop: 'Corn', areaHectares: 8 }),
]);
expect(screen.getByText('Південне поле')).toBeInTheDocument();
expect(screen.getByText('Західне поле')).toBeInTheDocument();
expect(screen.getByText('Пшениця')).toBeInTheDocument();
expect(screen.getByText('Кукурудза')).toBeInTheDocument();
expect(screen.getByText('12.5 га')).toBeInTheDocument();
expect(screen.getByText('8.0 га')).toBeInTheDocument();
});

it('caps the list at the first 6 fields', () => {
const seven: FieldDto[] = Array.from({ length: 7 }, (_, i) =>
makeField({ id: `f-${i}`, name: `Поле ${i}` }),
);
renderCard(seven);
expect(screen.getAllByRole('button', { name: /Поле \d+/ })).toHaveLength(6);
expect(screen.queryByText('Поле 6')).not.toBeInTheDocument();
});

it('falls back to notSeeded when a field has no currentCrop', () => {
renderCard([makeField({ currentCrop: undefined })]);
expect(screen.getByText('Не засіяно')).toBeInTheDocument();
});
});

/* ── Accessibility (Phase 2f) ─────────────────────────────────────── */

describe('FieldStatusCard — accessibility', () => {
it('exposes each row as a button with a field-specific accessible name', () => {
renderCard([makeField()]);
const row = screen.getByRole('button', { name: /Південне поле/ });
expect(row).toBeInTheDocument();
expect(row.getAttribute('aria-label')).toBe('Південне поле, Пшениця, 12.5 га');
});

it('uses the notSeeded fallback inside the accessible name', () => {
renderCard([makeField({ currentCrop: undefined })]);
const row = screen.getByRole('button', { name: /Південне поле/ });
expect(row.getAttribute('aria-label')).toBe('Південне поле, Не засіяно, 12.5 га');
});

it('makes each row keyboard-reachable (tabIndex=0)', () => {
renderCard([makeField()]);
const row = screen.getByRole('button', { name: /Південне поле/ });
expect(row).toHaveAttribute('tabindex', '0');
});

it('does not expose decorative crop tag, area pill or progress bar as interactive', () => {
renderCard([makeField()]);
// Three buttons total when one field is present:
// - the header "Усі поля" CTA
// - the row itself
// The cropTag / area / bar children are aria-hidden spans, not buttons.
expect(screen.getAllByRole('button')).toHaveLength(2);
expect(screen.queryByRole('button', { name: 'Пшениця' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '12.5 га' })).not.toBeInTheDocument();
});
});

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

describe('FieldStatusCard — activation', () => {
it('navigates to /fields when a row is clicked', () => {
renderCard([makeField()]);
fireEvent.click(screen.getByRole('button', { name: /Південне поле/ }));
expect(navigateMock).toHaveBeenCalledTimes(1);
expect(navigateMock).toHaveBeenCalledWith('/fields');
});

it('navigates when Enter is pressed while the row has focus', () => {
renderCard([makeField()]);
fireEvent.keyDown(screen.getByRole('button', { name: /Південне поле/ }), { key: 'Enter' });
expect(navigateMock).toHaveBeenCalledWith('/fields');
});

it('navigates when Space is pressed while the row has focus', () => {
renderCard([makeField()]);
fireEvent.keyDown(screen.getByRole('button', { name: /Південне поле/ }), { key: ' ' });
expect(navigateMock).toHaveBeenCalledWith('/fields');
});

it('Space activation calls preventDefault to suppress page scroll', () => {
renderCard([makeField()]);
const row = screen.getByRole('button', { name: /Південне поле/ });
const evt = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true });
row.dispatchEvent(evt);
expect(evt.defaultPrevented).toBe(true);
expect(navigateMock).toHaveBeenCalledWith('/fields');
});

it('does not navigate on unrelated keys (Tab, Escape, Shift, ArrowDown)', () => {
renderCard([makeField()]);
const row = screen.getByRole('button', { name: /Південне поле/ });
fireEvent.keyDown(row, { key: 'Tab' });
fireEvent.keyDown(row, { key: 'Escape' });
fireEvent.keyDown(row, { key: 'Shift' });
fireEvent.keyDown(row, { key: 'ArrowDown' });
expect(navigateMock).not.toHaveBeenCalled();
});

it('header "Усі поля" button (real <button>) still navigates correctly', () => {
renderCard([makeField()]);
fireEvent.click(screen.getByRole('button', { name: /Усі поля/ }));
expect(navigateMock).toHaveBeenCalledWith('/fields');
});
});
Loading