[SH-117] Give work-order board indicators accessible names (#137)

* fix(work-orders): give board indicators accessible names

Two work-order indicators conveyed their state visually only.

The carried-over pill rendered a bare number whose sole description was a
title attribute. It sits beside the reschedule pill, which also renders as a
small number, so the two counters were indistinguishable without a mouse.

Past Due drove a box shadow, a tint and an icon colour, while the control kept
the fixed name "Due Date". The state read by colour alone and never reached
assistive technology.

- Carried-over pill takes the reschedule pill's shape: an accessible name plus
  a tooltip, so both counters are identifiable.
- Due Date announces the past-due state, on the Emergency dialog control and on
  the standard date popover.
- Past-due is passed separately from `highlight`, which is also raised
  transiently to draw attention to a row and is not durable state to announce.

* fix: associate past-due status with date control

---------

Co-authored-by: Alexandre Brandizzi <alexandre.naime@luby.com.br>
Co-authored-by: Codex Review Integration <codex-review@local.invalid>
This commit is contained in:
Alexandre Brandizzi 2026-08-26 18:10:29 -03:00 • committed by GitHub
parent 3fb629dee3
commit d908b7e1da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 116 additions and 10 deletions

View file

@ -17,6 +17,8 @@ type DateCellProps = {
defaultMode?: "week" | "date";
/** When set, shows Clear in the popover so schedule can be removed (e.g. day-group → Unscheduled). */
onClear?: () => void;
/** Screen-reader-only status appended inside the button so the accessible name keeps the visible date and adds durable state. */
srStatusSuffix?: string;
};
export function DateCell({
@ -29,6 +31,7 @@ export function DateCell({
onSaveWeek,
defaultMode,
onClear,
srStatusSuffix,
}: DateCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [cellMode, setCellMode] = useState<"week" | "date">(
@ -81,6 +84,7 @@ export function DateCell({
</>
)}
</span>
{Boolean(srStatusSuffix) && <span className="sr-only">{srStatusSuffix}</span>}
<ChevronDown
size={14}
className="ml-2 shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100"

View file

@ -10,6 +10,12 @@ type DueDateCellProps = {
value: string;
q?: string;
highlight?: boolean;
/**
* Past-due is durable business state, unlike `highlight`, which is also raised
* transiently to draw attention to a row. Announce it so the state does not
* read by colour alone.
*/
pastDue?: boolean;
onSave: (date: string) => void;
};
@ -17,11 +23,26 @@ type DueDateCellProps = {
* Board / slide-over Due Date editor.
* Emergency → dedicated calendar Dialog; other types → standard DateCell popover.
*/
export function DueDateCell({ type, value, q, highlight, onSave }: DueDateCellProps) {
export function DueDateCell({
type,
value,
q,
highlight,
pastDue = false,
onSave,
}: DueDateCellProps) {
const [open, setOpen] = useState(false);
if (type !== "Emergency") {
return <DateCell value={value} q={q} highlight={highlight} onSave={onSave} />;
return (
<DateCell
value={value}
q={q}
highlight={highlight}
onSave={onSave}
srStatusSuffix={pastDue ? "Past due" : undefined}
/>
);
}
return (
@ -43,7 +64,7 @@ export function DueDateCell({ type, value, q, highlight, onSave }: DueDateCellPr
}
: undefined
}
aria-label="Due Date"
aria-label={pastDue ? "Due Date, past due" : "Due Date"}
aria-haspopup="dialog"
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">

View file

@ -51,14 +51,18 @@ export function CarriedOverCounterPill({ count }: { count: number }) {
return null;
}
const label = `Carried over ${count} times`;
return (
<span
title="Carried over"
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
style={MUTED_PILL_STYLE}
>
{count}
</span>
<Tooltip title="Carried over" arrow placement="top">
<span
aria-label={label}
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
style={MUTED_PILL_STYLE}
>
{count}
</span>
</Tooltip>
);
}

View file

@ -141,6 +141,7 @@ export function WoTableRowCells({
value={row.dueDate}
q={search}
highlight={forceDueDateHighlight || row.isPastDue}
pastDue={row.isPastDue}
onSave={(date) => onPatchRow({ dueDate: date })}
/>
</td>

View file

@ -0,0 +1,34 @@
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
CarriedOverCounterPill,
RescheduleCounterPill,
} from "@/app/(protected)/workorders/_components/list/table/cells/wo-counter-pills";
import { renderWithProviders } from "@/test/test-utils";
describe("CarriedOverCounterPill", () => {
it("hides the pill when the count is below 2", () => {
const { container } = renderWithProviders(<CarriedOverCounterPill count={1} />);
expect(container).toBeEmptyDOMElement();
});
it("names the count so it is not a bare number", () => {
renderWithProviders(<CarriedOverCounterPill count={3} />);
const pill = screen.getByLabelText(/Carried over 3 times/i);
expect(pill).toHaveTextContent("3");
});
it("is distinguishable from the reschedule pill beside it", () => {
renderWithProviders(
<>
<RescheduleCounterPill count={2} />
<CarriedOverCounterPill count={2} />
</>,
);
// Both render the same visible digit, so the accessible names are the only
// thing telling a non-sighted user which counter is which.
expect(screen.getByLabelText(/Rescheduled 2 times/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Carried over 2 times/i)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,42 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DueDateCell } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-cell";
import { renderWithProviders } from "@/test/test-utils";
describe("Due Date past-due announcement", () => {
it("names the past-due state on the Emergency due date control", () => {
renderWithProviders(
<DueDateCell type="Emergency" value="2026-07-03" pastDue highlight onSave={vi.fn()} />,
);
expect(screen.getByLabelText("Due Date, past due")).toBeInTheDocument();
});
it("keeps the plain name when the work order is not past due", () => {
renderWithProviders(<DueDateCell type="Emergency" value="2026-07-03" onSave={vi.fn()} />);
expect(screen.getByLabelText("Due Date")).toBeInTheDocument();
expect(screen.queryByLabelText("Due Date, past due")).not.toBeInTheDocument();
});
it("does not announce past due for a transient highlight alone", () => {
// `highlight` is also raised to draw attention to a row, which is not a
// durable state and must not be announced as one.
renderWithProviders(
<DueDateCell type="Emergency" value="2026-07-03" highlight onSave={vi.fn()} />,
);
expect(screen.getByLabelText("Due Date")).toBeInTheDocument();
});
it("announces past due on non-Emergency types too", () => {
renderWithProviders(<DueDateCell type="PM" value="2026-07-03" pastDue onSave={vi.fn()} />);
// The visible date stays the leading part of the button's accessible name
// and the durable past-due state is appended to it.
expect(screen.getByRole("button", { name: /07-03\s*Past due/ })).toBeInTheDocument();
});
it("does not announce past due for a non-Emergency transient highlight alone", () => {
// `highlight` is also raised to draw attention to a row, which is not a
// durable state and must not be announced as one.
renderWithProviders(<DueDateCell type="PM" value="2026-07-03" highlight onSave={vi.fn()} />);
expect(screen.getByRole("button", { name: "07-03" })).toBeInTheDocument();
});
});