diff --git a/frontend/src/pages/DashboardV2/components/UpcomingPanel.module.css b/frontend/src/pages/DashboardV2/components/UpcomingPanel.module.css
index c9a8b965..8b5af390 100644
--- a/frontend/src/pages/DashboardV2/components/UpcomingPanel.module.css
+++ b/frontend/src/pages/DashboardV2/components/UpcomingPanel.module.css
@@ -15,10 +15,33 @@
align-items: center;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+ /*
+ * Phase 2d migrated this from an inline `style={{cursor: 'pointer'}}`
+ * on the JSX so the focus-visible rule below can live alongside it
+ * without a runtime style override fight.
+ */
+ cursor: pointer;
}
.item:last-child { border-bottom: 0; }
+/*
+ * Phase 2d keyboard-focus affordance — mirrors the FieldCard / Phase 2c
+ * OperationsTimeline pattern:
+ * - .item:focus resets the platform-default outline so mouse-driven
+ * focus stays ring-free (the row has no hover background to begin
+ * with — there is nothing to "preserve parity" with, so the ring
+ * stands alone for keyboard users only).
+ * - .item:focus-visible draws a token-driven brand-coloured ring.
+ * 2px outset is enough to clear the row's bottom-border divider.
+ */
+.item:focus { outline: none; }
+
+.item:focus-visible {
+ outline: 2px solid var(--brand);
+ outline-offset: 2px;
+}
+
.dateChip {
font-family: var(--fm);
font-size: 11px;
diff --git a/frontend/src/pages/DashboardV2/components/UpcomingPanel.tsx b/frontend/src/pages/DashboardV2/components/UpcomingPanel.tsx
index 7a64c8a2..4a6d21f5 100644
--- a/frontend/src/pages/DashboardV2/components/UpcomingPanel.tsx
+++ b/frontend/src/pages/DashboardV2/components/UpcomingPanel.tsx
@@ -1,3 +1,4 @@
+import type { KeyboardEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { Cloud, Sun } from 'lucide-react';
import dayjs from 'dayjs';
@@ -22,6 +23,31 @@ interface Props {
* with a small weather strip at the bottom. Row 5, right column in the
* v2 IA. Filters operations to: not completed AND plannedDate within
* [today, today + windowDays].
+ *
+ * Phase 2d: pays down the same accessibility debt fixed by Phase 2b
+ * (FieldCard) and Phase 2c (dashboard OperationsTimeline). Each row was
+ * a
with onClick + an inline `cursor: pointer` and no keyboard
+ * affordance — unreachable by keyboard, unnamed for assistive tech.
+ *
+ * Pattern (identical to 2b/2c):
+ * - role="button" + tabIndex={0} + onKeyDown (Enter, Space) on each
+ * row, with preventDefault on Space to suppress page scroll
+ * - aria-label summarising the row (operation type + field + date,
+ * plus the optional area suffix when present) so screen readers
+ * announce a meaningful destination instead of an unnamed button
+ * - the decorative date chip and area pill are marked aria-hidden
+ * because their content is duplicated in aria-label
+ * - inline `cursor: pointer` migrated into the CSS module so the
+ * `:focus-visible` rule can live alongside it without a runtime
+ * style override fight
+ * - a token-driven :focus-visible ring lives in the CSS module
+ *
+ * No nested existed in the row, so the FieldCard "decorative
+ * button → span" step from Phase 2b does not apply here.
+ *
+ * Visual layout, the navigation target, the weather strip, the
+ * filter / sort / windowDays logic and all i18n strings are preserved
+ * verbatim. Card / Surface API is unchanged.
*/
export default function UpcomingPanel({ operations, windowDays = 7, weather }: Props) {
const { t } = useTranslation();
@@ -49,24 +75,54 @@ export default function UpcomingPanel({ operations, windowDays = 7, weather }: P
{upcoming.map((op) => {
const d = dayjs(op.plannedDate);
const isToday = d.isSame(today, 'day');
+ const dateStr = d.format('DD MMM');
const opLabel =
t.operationTypes[op.operationType as keyof typeof t.operationTypes] ?? op.operationType;
+ const haUnit = dash.haUnit ?? 'га';
+ const showArea =
+ typeof op.areaProcessed === 'number' && op.areaProcessed > 0;
+
+ // Concise screen-reader summary mirroring the row's visible
+ // content — operation type, field name, the same date the
+ // chip shows, and (when present) the area suffix already
+ // visible on the right. Keeps AT output in sync with the
+ // visual without inventing new i18n keys.
+ const ariaLabel = showArea
+ ? `${opLabel}, ${op.fieldName}, ${dateStr}, ${op.areaProcessed} ${haUnit}`
+ : `${opLabel}, ${op.fieldName}, ${dateStr}`;
+
+ const goToOperation = () => navigate(`/operations/${op.id}`);
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ goToOperation();
+ }
+ };
+
return (
navigate(`/operations/${op.id}`)}
- style={{ cursor: 'pointer' }}
+ role="button"
+ tabIndex={0}
+ aria-label={ariaLabel}
+ onClick={goToOperation}
+ onKeyDown={handleKeyDown}
>
-
- {d.format('DD MMM')}
+
+ {dateStr}
- {typeof op.areaProcessed === 'number' && op.areaProcessed > 0 && (
- {op.areaProcessed} {dash.haUnit ?? 'га'}
+ {showArea && (
+
+ {op.areaProcessed} {haUnit}
+
)}
);
diff --git a/frontend/src/pages/DashboardV2/components/__tests__/UpcomingPanel.test.tsx b/frontend/src/pages/DashboardV2/components/__tests__/UpcomingPanel.test.tsx
new file mode 100644
index 00000000..908ba36b
--- /dev/null
+++ b/frontend/src/pages/DashboardV2/components/__tests__/UpcomingPanel.test.tsx
@@ -0,0 +1,234 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import dayjs from 'dayjs';
+import UpcomingPanel from '../UpcomingPanel';
+import type { AgroOperationDto } from '../../../../types/operation';
+
+/* ── Mocks ─────────────────────────────────────────────────────────── */
+
+const navigateMock = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual =
+ await vi.importActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => navigateMock,
+ };
+});
+
+vi.mock('../../../../i18n', () => ({
+ useTranslation: () => ({
+ t: {
+ operationTypes: {
+ Sowing: 'Сівба',
+ Fertilizing: 'Внесення добрив',
+ Harvesting: 'Збір врожаю',
+ },
+ dashboard: {
+ noUpcoming: 'Немає запланованих',
+ haUnit: 'га',
+ },
+ },
+ lang: 'uk',
+ setLang: vi.fn(),
+ }),
+}));
+
+/* ── Helpers ───────────────────────────────────────────────────────── */
+
+const today = dayjs().startOf('day');
+const isoTomorrow = today.add(1, 'day').format('YYYY-MM-DD');
+const isoIn3Days = today.add(3, 'day').format('YYYY-MM-DD');
+const isoYesterday = today.subtract(1, 'day').format('YYYY-MM-DD');
+const isoIn30Days = today.add(30, 'day').format('YYYY-MM-DD');
+
+const makeOp = (overrides: Partial = {}): AgroOperationDto =>
+ ({
+ id: 'op-1',
+ operationType: 'Sowing',
+ fieldId: 'f-1',
+ fieldName: 'Південне поле',
+ plannedDate: isoTomorrow,
+ isCompleted: false,
+ ...overrides,
+ } as AgroOperationDto);
+
+const renderPanel = (
+ ops: AgroOperationDto[],
+ weather?: { tempC: number; condition: 'clear' | 'cloudy'; location: string },
+) =>
+ render(
+
+
+ ,
+ );
+
+beforeEach(() => navigateMock.mockReset());
+
+/* ── Empty state ───────────────────────────────────────────────────── */
+
+describe('UpcomingPanel — empty state', () => {
+ it('renders the empty placeholder when no operations supplied', () => {
+ renderPanel([]);
+ expect(screen.getByText('Немає запланованих')).toBeInTheDocument();
+ expect(screen.queryAllByRole('button')).toHaveLength(0);
+ });
+
+ it('renders the empty placeholder when all operations are already completed', () => {
+ renderPanel([makeOp({ isCompleted: true })]);
+ expect(screen.getByText('Немає запланованих')).toBeInTheDocument();
+ expect(screen.queryAllByRole('button')).toHaveLength(0);
+ });
+
+ it('renders the empty placeholder when operations fall outside the 7-day window', () => {
+ renderPanel([
+ makeOp({ id: 'past', plannedDate: isoYesterday }),
+ makeOp({ id: 'far', plannedDate: isoIn30Days }),
+ ]);
+ expect(screen.getByText('Немає запланованих')).toBeInTheDocument();
+ expect(screen.queryAllByRole('button')).toHaveLength(0);
+ });
+});
+
+/* ── Render ────────────────────────────────────────────────────────── */
+
+describe('UpcomingPanel — primary render', () => {
+ it('renders one row per upcoming operation with type, field name and date chip', () => {
+ renderPanel([
+ makeOp(),
+ makeOp({
+ id: 'op-2',
+ operationType: 'Harvesting',
+ fieldName: 'Західне поле',
+ plannedDate: isoIn3Days,
+ }),
+ ]);
+
+ expect(screen.getByText('Сівба')).toBeInTheDocument();
+ expect(screen.getByText('Південне поле')).toBeInTheDocument();
+ expect(screen.getByText('Збір врожаю')).toBeInTheDocument();
+ expect(screen.getByText('Західне поле')).toBeInTheDocument();
+ // Both date chips rendered (DD MMM format from dayjs).
+ expect(screen.getByText(dayjs(isoTomorrow).format('DD MMM'))).toBeInTheDocument();
+ expect(screen.getByText(dayjs(isoIn3Days).format('DD MMM'))).toBeInTheDocument();
+ });
+
+ it('renders the area suffix when areaProcessed > 0', () => {
+ renderPanel([makeOp({ areaProcessed: 12.5 } as Partial)]);
+ expect(screen.getByText('12.5 га')).toBeInTheDocument();
+ });
+
+ it('omits the area suffix when areaProcessed is missing or zero', () => {
+ renderPanel([makeOp({ areaProcessed: 0 } as Partial)]);
+ // No element should match the " га" pattern.
+ expect(screen.queryByText(/^\d.*га$/)).not.toBeInTheDocument();
+ });
+
+ it('renders the optional weather strip when weather prop is supplied', () => {
+ renderPanel([makeOp()], { tempC: 18, condition: 'clear', location: 'Київ' });
+ expect(screen.getByText('+18°C')).toBeInTheDocument();
+ expect(screen.getByText('· Київ')).toBeInTheDocument();
+ });
+
+ it('exposes exactly one button per upcoming row (no nested interactives)', () => {
+ renderPanel([makeOp(), makeOp({ id: 'op-2', plannedDate: isoIn3Days })]);
+ expect(screen.getAllByRole('button')).toHaveLength(2);
+ });
+});
+
+/* ── A11y (Phase 2d) ───────────────────────────────────────────────── */
+
+describe('UpcomingPanel — accessibility', () => {
+ it('gives each row an accessible name summarising the operation', () => {
+ renderPanel([makeOp()]);
+ const row = screen.getByRole('button', { name: /Сівба/ });
+ expect(row).toBeInTheDocument();
+ expect(row.getAttribute('aria-label')).toBe(
+ `Сівба, Південне поле, ${dayjs(isoTomorrow).format('DD MMM')}`,
+ );
+ });
+
+ it('appends the area suffix into the accessible name when present', () => {
+ renderPanel([makeOp({ areaProcessed: 7 } as Partial)]);
+ const row = screen.getByRole('button', { name: /Сівба/ });
+ expect(row.getAttribute('aria-label')).toBe(
+ `Сівба, Південне поле, ${dayjs(isoTomorrow).format('DD MMM')}, 7 га`,
+ );
+ });
+
+ it('makes each row keyboard-reachable (tabIndex=0)', () => {
+ renderPanel([makeOp()]);
+ const row = screen.getByRole('button', { name: /Сівба/ });
+ expect(row).toHaveAttribute('tabindex', '0');
+ });
+
+ it('does not expose decorative date chip or area pill as interactive elements', () => {
+ renderPanel([makeOp({ areaProcessed: 7 } as Partial)]);
+ // Only the row itself should be a button; the date chip and the
+ // area pill are presentational s with aria-hidden.
+ expect(screen.getAllByRole('button')).toHaveLength(1);
+ expect(
+ screen.queryByRole('button', { name: dayjs(isoTomorrow).format('DD MMM') }),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: '7 га' })).not.toBeInTheDocument();
+ });
+});
+
+/* ── Activation (mouse + keyboard) ─────────────────────────────────── */
+
+describe('UpcomingPanel — activation', () => {
+ it('navigates to /operations/{id} when a row is clicked', () => {
+ renderPanel([makeOp({ id: 'op-77' })]);
+ fireEvent.click(screen.getByRole('button', { name: /Сівба/ }));
+ expect(navigateMock).toHaveBeenCalledTimes(1);
+ expect(navigateMock).toHaveBeenCalledWith('/operations/op-77');
+ });
+
+ it('navigates when Enter is pressed while the row has focus', () => {
+ renderPanel([makeOp({ id: 'op-77' })]);
+ fireEvent.keyDown(screen.getByRole('button', { name: /Сівба/ }), { key: 'Enter' });
+ expect(navigateMock).toHaveBeenCalledTimes(1);
+ expect(navigateMock).toHaveBeenCalledWith('/operations/op-77');
+ });
+
+ it('navigates when Space is pressed while the row has focus', () => {
+ renderPanel([makeOp({ id: 'op-77' })]);
+ fireEvent.keyDown(screen.getByRole('button', { name: /Сівба/ }), { key: ' ' });
+ expect(navigateMock).toHaveBeenCalledTimes(1);
+ expect(navigateMock).toHaveBeenCalledWith('/operations/op-77');
+ });
+
+ it('Space activation calls preventDefault to suppress page scroll', () => {
+ renderPanel([makeOp({ id: 'op-77' })]);
+ 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('/operations/op-77');
+ });
+
+ it('does not navigate on unrelated keys (Tab, Escape, Shift, ArrowDown)', () => {
+ renderPanel([makeOp()]);
+ 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('routes to the correct id when multiple rows are present', () => {
+ renderPanel([
+ makeOp({ id: 'op-A', operationType: 'Sowing' }),
+ makeOp({ id: 'op-B', operationType: 'Harvesting', plannedDate: isoIn3Days }),
+ ]);
+
+ fireEvent.click(screen.getByRole('button', { name: /Збір врожаю/ }));
+
+ expect(navigateMock).toHaveBeenCalledTimes(1);
+ expect(navigateMock).toHaveBeenCalledWith('/operations/op-B');
+ });
+});