mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-05 17:42:06 +00:00
feat(work-orders): expose accessible names on board row flags
Give keyboard and screen-reader users color-independent labels when setting, changing, or clearing a work-order flag.
This commit is contained in:
parent
5e38c93b1e
commit
b619b8ae13
9 changed files with 181 additions and 55 deletions
|
|
@ -41,6 +41,7 @@ function indicatorCount(draft: WorkOrderAdvancedFilters): number {
|
||||||
function FlagColorDot({ value }: { value: string }) {
|
function FlagColorDot({ value }: { value: string }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
className="size-3 shrink-0 rounded-full"
|
className="size-3 shrink-0 rounded-full"
|
||||||
style={{ background: value, border: "1px solid var(--color-border)" }}
|
style={{ background: value, border: "1px solid var(--color-border)" }}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Flag, X } from "lucide-react";
|
import { Flag, X } from "lucide-react";
|
||||||
import { Popover } from "@mui/material";
|
import { Popover } from "@mui/material";
|
||||||
import { FLAG_COLORS } from "@/domain/work-orders/flag-colors";
|
import { FLAG_COLORS, flagTriggerAccessibleName } from "@/domain/work-orders/flag-colors";
|
||||||
|
|
||||||
type FlagCellProps = {
|
type FlagCellProps = {
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
|
|
@ -11,11 +11,16 @@ type FlagCellProps = {
|
||||||
|
|
||||||
export function FlagCell({ color, disabled, onChange }: FlagCellProps) {
|
export function FlagCell({ color, disabled, onChange }: FlagCellProps) {
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
const triggerName = flagTriggerAccessibleName(color);
|
||||||
|
const popoverOpen = Boolean(anchorEl);
|
||||||
|
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return (
|
return (
|
||||||
<span className="flex h-[22px] w-[22px] items-center justify-center opacity-30">
|
<span
|
||||||
<Flag size={14} />
|
className="flex h-[22px] w-[22px] items-center justify-center opacity-30"
|
||||||
|
aria-label={triggerName}
|
||||||
|
>
|
||||||
|
<Flag size={14} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -24,15 +29,19 @@ export function FlagCell({ color, disabled, onChange }: FlagCellProps) {
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={triggerName}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={popoverOpen}
|
||||||
|
title={triggerName}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setAnchorEl(e.currentTarget);
|
setAnchorEl(e.currentTarget);
|
||||||
}}
|
}}
|
||||||
title={color ? "Flagged — click to change" : "Add color flag"}
|
className="flex h-[22px] w-[22px] items-center justify-center transition-opacity hover:opacity-80 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--color-primary)]"
|
||||||
className="flex h-[22px] w-[22px] items-center justify-center transition-opacity hover:opacity-80"
|
|
||||||
>
|
>
|
||||||
<Flag
|
<Flag
|
||||||
size={14}
|
size={14}
|
||||||
|
aria-hidden="true"
|
||||||
strokeWidth={color ? 1.5 : 1.75}
|
strokeWidth={color ? 1.5 : 1.75}
|
||||||
style={{
|
style={{
|
||||||
color: color ?? "var(--color-text-muted)",
|
color: color ?? "var(--color-text-muted)",
|
||||||
|
|
@ -41,37 +50,47 @@ export function FlagCell({ color, disabled, onChange }: FlagCellProps) {
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<Popover
|
<Popover
|
||||||
open={Boolean(anchorEl)}
|
open={popoverOpen}
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
onClose={() => setAnchorEl(null)}
|
onClose={() => setAnchorEl(null)}
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
slotProps={{ paper: { className: "rounded-lg border border-border p-2" } }}
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
className: "rounded-lg border border-border p-2",
|
||||||
|
"aria-label": "Choose flag color",
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-4 gap-1.5">
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
{FLAG_COLORS.map((c) => (
|
{FLAG_COLORS.map((c) => {
|
||||||
<button
|
const selected = color === c.value;
|
||||||
key={c.value}
|
return (
|
||||||
type="button"
|
<button
|
||||||
title={c.name}
|
key={c.value}
|
||||||
onClick={() => {
|
type="button"
|
||||||
onChange(c.value);
|
aria-label={c.name}
|
||||||
setAnchorEl(null);
|
aria-pressed={selected}
|
||||||
}}
|
title={c.name}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded transition-transform hover:scale-110"
|
onClick={() => {
|
||||||
>
|
onChange(c.value);
|
||||||
<span
|
setAnchorEl(null);
|
||||||
className="rounded-full"
|
|
||||||
style={{
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
background: c.value,
|
|
||||||
boxShadow:
|
|
||||||
color === c.value ? `0 0 0 2px var(--card), 0 0 0 4px ${c.value}` : "none",
|
|
||||||
}}
|
}}
|
||||||
/>
|
className="flex h-7 w-7 items-center justify-center rounded transition-transform hover:scale-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--color-primary)]"
|
||||||
</button>
|
>
|
||||||
))}
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="rounded-full"
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
background: c.value,
|
||||||
|
boxShadow: selected ? `0 0 0 2px var(--card), 0 0 0 4px ${c.value}` : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -81,7 +100,7 @@ export function FlagCell({ color, disabled, onChange }: FlagCellProps) {
|
||||||
}}
|
}}
|
||||||
className="mt-2 flex w-full items-center gap-1.5 border-t border-border px-1 pt-2 text-xs text-[var(--color-text-secondary)] transition-colors hover:opacity-80"
|
className="mt-2 flex w-full items-center gap-1.5 border-t border-border px-1 pt-2 text-xs text-[var(--color-text-secondary)] transition-colors hover:opacity-80"
|
||||||
>
|
>
|
||||||
<X size={12} /> Clear flag
|
<X size={12} aria-hidden="true" /> Clear flag
|
||||||
</button>
|
</button>
|
||||||
</Popover>
|
</Popover>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export const WO_TABLE_COLS = [
|
export const WO_TABLE_COLS = [
|
||||||
{ key: "grip", label: "", w: 28 },
|
{ key: "grip", label: "", w: 28 },
|
||||||
{ key: "flag", label: "", w: 28 },
|
{ key: "flag", label: "", srLabel: "Flag", w: 28 },
|
||||||
{ key: "site", label: "SITE", w: 168 },
|
{ key: "site", label: "SITE", w: 168 },
|
||||||
{ key: "wo", label: "WO", w: 120 },
|
{ key: "wo", label: "WO", w: 120 },
|
||||||
{ key: "type", label: "TYPE OF WO", w: 108 },
|
{ key: "type", label: "TYPE OF WO", w: 108 },
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,32 @@ export function WoTableHead() {
|
||||||
return (
|
return (
|
||||||
<thead className="sticky top-0 z-10">
|
<thead className="sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
{WO_TABLE_COLS.map((col) => (
|
{WO_TABLE_COLS.map((col) => {
|
||||||
<th
|
const srLabel = "srLabel" in col ? col.srLabel : undefined;
|
||||||
key={col.key}
|
return (
|
||||||
style={{
|
<th
|
||||||
width: col.w,
|
key={col.key}
|
||||||
minWidth: col.w,
|
scope="col"
|
||||||
padding: "11px 14px",
|
style={{
|
||||||
textAlign: "left",
|
width: col.w,
|
||||||
fontSize: 10.5,
|
minWidth: col.w,
|
||||||
fontWeight: 700,
|
padding: "11px 14px",
|
||||||
textTransform: "uppercase",
|
textAlign: "left",
|
||||||
letterSpacing: "0.06em",
|
fontSize: 10.5,
|
||||||
color: "var(--foreground)",
|
fontWeight: 700,
|
||||||
whiteSpace: "nowrap",
|
textTransform: "uppercase",
|
||||||
background: "var(--color-bg-muted)",
|
letterSpacing: "0.06em",
|
||||||
borderBottom: "1px solid var(--border)",
|
color: "var(--foreground)",
|
||||||
}}
|
whiteSpace: "nowrap",
|
||||||
>
|
background: "var(--color-bg-muted)",
|
||||||
{col.label}
|
borderBottom: "1px solid var(--border)",
|
||||||
</th>
|
}}
|
||||||
))}
|
>
|
||||||
|
{col.label}
|
||||||
|
{Boolean(srLabel) && <span className="sr-only">{srLabel}</span>}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,12 @@ export function WoTableInlineRow({
|
||||||
<td style={{ padding: "0 0 0 6px" }} />
|
<td style={{ padding: "0 0 0 6px" }} />
|
||||||
|
|
||||||
<td style={{ padding: "0 2px 0 8px" }}>
|
<td style={{ padding: "0 2px 0 8px" }}>
|
||||||
<Flag size={14} strokeWidth={1.75} style={{ color: "var(--color-border)" }} />
|
<Flag
|
||||||
|
size={14}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ color: "var(--color-border)" }}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<WoTableInlineRowCells
|
<WoTableInlineRowCells
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,23 @@ export const FLAG_COLORS: { name: string; value: string }[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FLAG_COLOR_VALUES = FLAG_COLORS.map((c) => c.value);
|
export const FLAG_COLOR_VALUES = FLAG_COLORS.map((c) => c.value);
|
||||||
|
|
||||||
|
export function flagColorName(value: string | null | undefined): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = value.toLowerCase();
|
||||||
|
return FLAG_COLORS.find((color) => color.value.toLowerCase() === normalized)?.name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accessible trigger name — never color-only. */
|
||||||
|
export function flagTriggerAccessibleName(color: string | null | undefined): string {
|
||||||
|
const name = flagColorName(color);
|
||||||
|
if (name) {
|
||||||
|
return `${name} flag`;
|
||||||
|
}
|
||||||
|
if (color) {
|
||||||
|
return "Flagged";
|
||||||
|
}
|
||||||
|
return "Add flag";
|
||||||
|
}
|
||||||
|
|
|
||||||
53
src/test/app/(protected)/workorders/flag-cell.test.tsx
Normal file
53
src/test/app/(protected)/workorders/flag-cell.test.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { FlagCell } from "@/app/(protected)/workorders/_components/list/table/cells/flag-cell";
|
||||||
|
import { WoTableHead } from "@/app/(protected)/workorders/_components/list/table/wo-table-head";
|
||||||
|
|
||||||
|
describe("FlagCell", () => {
|
||||||
|
it("sets, changes, and clears a flag by named controls", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const { rerender } = render(<FlagCell color={null} onChange={onChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add flag" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Red" }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith("#EF4444");
|
||||||
|
|
||||||
|
rerender(<FlagCell color="#EF4444" onChange={onChange} />);
|
||||||
|
expect(screen.getByRole("button", { name: "Red flag" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Red flag" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Blue" }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith("#3B82F6");
|
||||||
|
|
||||||
|
rerender(<FlagCell color="#3B82F6" onChange={onChange} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Blue flag" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Clear flag" }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes a non-color name when the hex is not in the palette", () => {
|
||||||
|
render(<FlagCell color="#000000" onChange={vi.fn()} />);
|
||||||
|
expect(screen.getByRole("button", { name: "Flagged" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a disabled flag without exposing a button", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<FlagCell color="#EF4444" disabled onChange={onChange} />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Red flag")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Red flag" })).toBeNull();
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WoTableHead flag column", () => {
|
||||||
|
it("exposes a screen-reader label for the flag column", () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<WoTableHead />
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("columnheader", { name: "Flag" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -98,8 +98,8 @@ describe("WoTableRow completed selective lock", () => {
|
||||||
{ withAuth: false },
|
{ withAuth: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTitle("Add color flag")).toBeEnabled();
|
expect(screen.getByRole("button", { name: "Add flag" })).toBeEnabled();
|
||||||
fireEvent.click(screen.getByTitle("Add color flag"));
|
fireEvent.click(screen.getByRole("button", { name: "Add flag" }));
|
||||||
expect(screen.getByText("Clear flag")).toBeInTheDocument();
|
expect(screen.getByText("Clear flag")).toBeInTheDocument();
|
||||||
expect(screen.queryByTitle("Edit")).not.toBeInTheDocument();
|
expect(screen.queryByTitle("Edit")).not.toBeInTheDocument();
|
||||||
expect(screen.getByTitle("View details")).toBeInTheDocument();
|
expect(screen.getByTitle("View details")).toBeInTheDocument();
|
||||||
|
|
|
||||||
23
src/test/domain/work-orders/flag-colors.test.ts
Normal file
23
src/test/domain/work-orders/flag-colors.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { flagColorName, flagTriggerAccessibleName } from "@/domain/work-orders/flag-colors";
|
||||||
|
|
||||||
|
describe("flag color accessible names", () => {
|
||||||
|
it("resolves palette hex values to names", () => {
|
||||||
|
expect(flagColorName("#EF4444")).toBe("Red");
|
||||||
|
expect(flagColorName("#ef4444")).toBe("Red");
|
||||||
|
expect(flagColorName("#3B82F6")).toBe("Blue");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty or unknown values", () => {
|
||||||
|
expect(flagColorName(null)).toBeNull();
|
||||||
|
expect(flagColorName(undefined)).toBeNull();
|
||||||
|
expect(flagColorName("")).toBeNull();
|
||||||
|
expect(flagColorName("#000000")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds trigger names that are not color-only", () => {
|
||||||
|
expect(flagTriggerAccessibleName(null)).toBe("Add flag");
|
||||||
|
expect(flagTriggerAccessibleName("#EF4444")).toBe("Red flag");
|
||||||
|
expect(flagTriggerAccessibleName("#000000")).toBe("Flagged");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue