refactor(ui): enforce maintainable conditional feedback

This commit is contained in:
Alexandre Brandizzi 2026-07-24 11:32:58 -03:00
parent 21b7de57a1
commit 392af77b91
47 changed files with 308 additions and 106 deletions

View file

@ -18,6 +18,33 @@ state. Use a ternary only when both branches render meaningful alternatives.
ESLint rejects `condition ? <Element /> : null`. This keeps one-sided conditions visually ESLint rejects `condition ? <Element /> : null`. This keeps one-sided conditions visually
distinct from real either-or UI decisions. distinct from real either-or UI decisions.
The left operand of `&&` must be entirely boolean. `&&` renders its left operand when it is falsy
and non-boolean (notably `{count && <X />}` renders `0`), so a type-aware ESLint rule,
`seahaven/no-non-boolean-jsx-and`, is enforced at `error` across the repository. It asks the
TypeScript checker for the type of the left operand and reports unless every union constituent is
boolean-like, so `boolean | undefined` and `string | null` fail just as `number` does. The rule
fails closed: if type services are unavailable in a governed TSX file it reports rather than
silently claiming safety. The `when` prop on `Text` is typed `boolean`, so TypeScript enforces the
same constraint at that component boundary.
Approved guard forms (choose by semantics, not as a blind codemod):
- Presence-only values where falsy means "absent" — errors, optional strings shown only when set
(descriptions, notes, reasons), and optional objects (locations, detail records): coerce with
`Boolean(value)` (or `Boolean(a || b)` for a composite presence test) before `&&`.
`{Boolean(error) && <Alert />}` and `{Boolean(description) && <Text />}` are the canonical forms.
- Values where `0` or `""` is meaningful, or where a non-null value must flow into a typed prop or
helper inside the branch: use an explicit nullish/range comparison so the operand is boolean and
TypeScript can still narrow. `{count > 0 && ...}`, `{value != null && ...}`, and
`{isEdit && id != null && <Panel vendorId={id} />}` keep `0`/`""` semantics and preserve
narrowing.
- Element-slot props (`icon`, `action`, `actions`) are typed `ReactElement`, not `ReactNode`: the
slot holds one element (or fragment), and the render branch coerces with `Boolean(prop)`. Do not
widen these back to `ReactNode`, since a slot is never a meaningful `0`/`""`.
Never weaken, disable, baseline, or add per-line exceptions to the rule. A new one-sided condition
that needs a non-boolean operand must be rewritten into one of the approved forms above.
## Typography and feedback ## Typography and feedback
Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions, Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions,
@ -29,9 +56,31 @@ code, and asynchronous feedback. It owns:
- accessible `alert` and `status` live regions for error and feedback text; - accessible `alert` and `status` live regions for error and feedback text;
- one-sided conditional text through `when`. - one-sided conditional text through `when`.
The variant contract is non-overridable: `component`, `role`, and `aria-live` are omitted from
`TextProps` (TypeScript blocks them) and the remaining props are spread before the variant-owned
attributes so the variant element, tone, and live-region role always win at runtime. Use `as` to
choose the rendered element and `tone` to choose the color; do not pass `component`, `role`, or
`aria-live` directly.
Live-region behavior:
- `feedback` (polite `status`) keeps the region mounted and toggles its text content via `when`, so
the polite region exists before its content changes and is announced reliably. Mounting an
already-populated status node on demand is not announceable on most screen-reader/browser pairs.
- `error` (assertive `alert`) mounts on demand (`when={false}` unmounts it). Alert-on-mount is the
expected error pattern, so the shipped `when={Boolean(error)}` usages are correct.
ESLint rejects raw paragraph and heading elements. Existing MUI `Typography` usages remain valid, ESLint rejects raw paragraph and heading elements. Existing MUI `Typography` usages remain valid,
but new shared UI should prefer `Text` so semantics and design tokens do not drift. but new shared UI should prefer `Text` so semantics and design tokens do not drift.
The `vp-error` CSS token is presentational chrome for vendor-portal error cards and may only appear
on the `Text` component. ESLint flags any non-`Text` JSX element whose `className` is a static
string literal containing `vp-error` (e.g. `<div className="vp-error">`, `<section
className="vp-error extra">`); pair the error message with `variant="error"`. The rule enforces the
static surface only. It cannot resolve dynamic or composed class values
(`className={cn("vp-error", ...)}`, template literals with expressions, or expression-wrapped
strings), so do not compose `vp-error` dynamically to bypass it — prefer `Text variant="error"`.
## Forms and mutations ## Forms and mutations
Use the libraries already established in the application: Use the libraries already established in the application:

View file

@ -0,0 +1,95 @@
import ts from "typescript";
/**
* Local type-aware rule: the left operand of `&&` in JSX must be entirely
* boolean.
*
* `{value && <Element />}` renders its left operand when it is falsy and
* non-boolean (notably `{count && <X />}` renders `0`), so the left operand
* must be `boolean` (or boolean literals) across the whole union. The rule
* asks the TypeScript checker for the type of the left operand and reports
* unless every union constituent is boolean-like.
*
* Type-aware by design: a selector that cannot see operand types would give
* false confidence rather than enforcement. If type services are unexpectedly
* unavailable in a governed TSX file, the rule fails closed (reports) instead
* of silently claiming the expression is safe.
*/
const booleanLikeFlags = ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral;
function isBooleanLikeType(type) {
return (type.flags & booleanLikeFlags) !== 0;
}
function isEntirelyBoolean(type) {
if (type.isUnion()) {
return type.types.every((constituent) => isBooleanLikeType(constituent));
}
return isBooleanLikeType(type);
}
const transparentExpressionTypes = new Set([
"ChainExpression",
"ParenthesizedExpression",
"TSAsExpression",
"TSNonNullExpression",
"TSTypeAssertion",
]);
function isInRenderedPosition(node) {
let current = node;
while (current.parent) {
const parent = current.parent;
if (parent.type === "JSXExpressionContainer") {
return parent.parent?.type !== "JSXAttribute";
}
if (parent.type === "LogicalExpression" || transparentExpressionTypes.has(parent.type)) {
current = parent;
continue;
}
return false;
}
return false;
}
const rule = {
meta: {
type: "problem",
docs: {
description: "Require the left operand of `&&` in JSX to be entirely boolean",
},
schema: [],
messages: {
nonBooleanLeft:
'The left operand of `&&` in JSX must be boolean. Non-boolean falsy operands (e.g. `0`, `""`) render into the DOM; coerce with `Boolean(...)` or `!!` before `&&`.',
typeServicesUnavailable:
"Type-aware boolean-safety check is unavailable for this JSX expression. This file must be part of a TypeScript project with type information so the rule can enforce safely.",
},
},
create(context) {
const services = context.sourceCode?.parserServices ?? context.parserServices;
return {
LogicalExpression(node) {
if (node.operator !== "&&") {
return;
}
if (!isInRenderedPosition(node)) {
return;
}
if (!services || services.program === null) {
context.report({ node, messageId: "typeServicesUnavailable" });
return;
}
const leftType = services.getTypeAtLocation(node.left);
if (!isEntirelyBoolean(leftType)) {
context.report({ node: node.left, messageId: "nonBooleanLeft" });
}
},
};
},
};
export default rule;

View file

@ -5,6 +5,14 @@ import pluginReactRefresh from "eslint-plugin-react-refresh";
import globals from "globals"; import globals from "globals";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
import noNonBooleanJsxAnd from "./eslint-rules/no-non-boolean-jsx-and.js";
const localRules = {
rules: {
"no-non-boolean-jsx-and": noNonBooleanJsxAnd,
},
};
const legacyIgnores = [ const legacyIgnores = [
"src/pages/**", "src/pages/**",
"src/app/store.js", "src/app/store.js",
@ -54,6 +62,7 @@ export default tseslint.config(
plugins: { plugins: {
"react-hooks": pluginReactHooks, "react-hooks": pluginReactHooks,
"react-refresh": pluginReactRefresh, "react-refresh": pluginReactRefresh,
seahaven: localRules,
}, },
rules: { rules: {
...pluginReactHooks.configs.recommended.rules, ...pluginReactHooks.configs.recommended.rules,
@ -62,6 +71,7 @@ export default tseslint.config(
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
"seahaven/no-non-boolean-jsx-and": "error",
"no-restricted-syntax": [ "no-restricted-syntax": [
"error", "error",
{ {
@ -78,8 +88,9 @@ export default tseslint.config(
}, },
{ {
selector: selector:
"JSXOpeningElement[name.name='div'] > JSXAttribute[name.name='className'][value.value='vp-error']", "JSXOpeningElement:not([name.name='Text']) > JSXAttribute[name.name='className'][value.type='Literal'][value.value=/vp-error/]",
message: 'Use Text variant="error" for accessible, consistent error feedback.', message:
'Use the Text component for the vp-error class; pair the error message with variant="error" so feedback stays accessible and consistent.',
}, },
], ],
}, },

View file

@ -11,8 +11,8 @@
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"lint": "eslint .", "lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix --max-warnings=0",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"prepare": "husky" "prepare": "husky"

View file

@ -75,7 +75,9 @@ export function LoginForm({
disabled={isLoggingIn} disabled={isLoggingIn}
error={Boolean(errors.password)} error={Boolean(errors.password)}
/> />
{loginError && <FormErrorMessage message="Invalid email or password. Please try again." />} {Boolean(loginError) && (
<FormErrorMessage message="Invalid email or password. Please try again." />
)}
</FormField> </FormField>
<Button <Button

View file

@ -110,7 +110,7 @@ export default function AccountFormPage() {
</Button> </Button>
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load account"} {error instanceof Error ? error.message : "Failed to load account"}
</Alert> </Alert>

View file

@ -118,7 +118,7 @@ export default function AccountsListPage() {
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load accounts"} {error instanceof Error ? error.message : "Failed to load accounts"}
</Alert> </Alert>

View file

@ -91,7 +91,7 @@ export default function AssetFormPage() {
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h5">{isEdit ? "Edit Asset" : "Create Asset"}</Typography> <Typography variant="h5">{isEdit ? "Edit Asset" : "Create Asset"}</Typography>
{(error || optionsError) && ( {Boolean(error || optionsError) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error {error instanceof Error
? error.message ? error.message

View file

@ -116,7 +116,7 @@ export default function AssetsListPage() {
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load assets"} {error instanceof Error ? error.message : "Failed to load assets"}
</Alert> </Alert>

View file

@ -120,7 +120,7 @@ export default function EventFormPage() {
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h5">{isEdit ? "Edit Event" : "Create Event"}</Typography> <Typography variant="h5">{isEdit ? "Edit Event" : "Create Event"}</Typography>
{error && <Alert severity="error">Failed to load event</Alert>} {Boolean(error) && <Alert severity="error">Failed to load event</Alert>}
<Box component="form" onSubmit={handleSubmit(onSubmit)}> <Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={3}> <Stack spacing={3}>

View file

@ -127,7 +127,7 @@ export default function ContactFormPage() {
<Stack spacing={4}> <Stack spacing={4}>
<Typography variant="h5">{isEdit ? "Edit Contact" : "Create Contact"}</Typography> <Typography variant="h5">{isEdit ? "Edit Contact" : "Create Contact"}</Typography>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load contact"} {error instanceof Error ? error.message : "Failed to load contact"}
</Alert> </Alert>

View file

@ -103,7 +103,7 @@ export default function ContactsListPage() {
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load contacts"} {error instanceof Error ? error.message : "Failed to load contacts"}
</Alert> </Alert>

View file

@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
</Typography> </Typography>
)} )}
{description && ( {Boolean(description) && (
<Typography <Typography
variant="body2" variant="body2"
sx={{ sx={{
@ -106,7 +106,7 @@ export default function DashboardPage() {
</Typography> </Typography>
{isFetching && !isLoading && <CircularProgress size={16} />} {isFetching && !isLoading && <CircularProgress size={16} />}
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load dashboard stats"} {error instanceof Error ? error.message : "Failed to load dashboard stats"}
</Alert> </Alert>

View file

@ -83,7 +83,7 @@ function SelectField({
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
{helperText && ( {Boolean(helperText) && (
<Typography variant="caption" color="error" className="mt-1"> <Typography variant="caption" color="error" className="mt-1">
{helperText} {helperText}
</Typography> </Typography>
@ -193,7 +193,7 @@ export default function EmployeeFormPage() {
</Button> </Button>
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load employee"} {error instanceof Error ? error.message : "Failed to load employee"}
</Alert> </Alert>

View file

@ -124,7 +124,7 @@ export default function EmployeesListPage() {
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load employees"} {error instanceof Error ? error.message : "Failed to load employees"}
</Alert> </Alert>

View file

@ -102,7 +102,7 @@ export default function FollowUpFormPage() {
<Stack spacing={4}> <Stack spacing={4}>
<Typography variant="h5">{isEdit ? "Edit Follow-up" : "Create Follow-up"}</Typography> <Typography variant="h5">{isEdit ? "Edit Follow-up" : "Create Follow-up"}</Typography>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load follow-up"} {error instanceof Error ? error.message : "Failed to load follow-up"}
</Alert> </Alert>

View file

@ -129,7 +129,7 @@ export default function FollowUpsListPage() {
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load follow-ups"} {error instanceof Error ? error.message : "Failed to load follow-ups"}
</Alert> </Alert>

View file

@ -70,7 +70,7 @@ function KpiCard({ title, description, value, icon, color, onClick, loading }: K
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
</Typography> </Typography>
)} )}
{description && ( {Boolean(description) && (
<Typography <Typography
variant="body2" variant="body2"
sx={{ sx={{
@ -106,7 +106,7 @@ export default function DashboardPage() {
</Typography> </Typography>
{isFetching && !isLoading && <CircularProgress size={16} />} {isFetching && !isLoading && <CircularProgress size={16} />}
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load dashboard stats"} {error instanceof Error ? error.message : "Failed to load dashboard stats"}
</Alert> </Alert>

View file

@ -114,7 +114,7 @@ export default function LocationFormPage() {
</Button> </Button>
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load location"} {error instanceof Error ? error.message : "Failed to load location"}
</Alert> </Alert>

View file

@ -118,7 +118,7 @@ export default function LocationsListPage() {
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load locations"} {error instanceof Error ? error.message : "Failed to load locations"}
</Alert> </Alert>

View file

@ -80,8 +80,8 @@ export default function PmScheduleFormPage() {
<Stack spacing={3}> <Stack spacing={3}>
<Typography variant="h5">{isEdit ? "Edit PM Schedule" : "Create PM Schedule"}</Typography> <Typography variant="h5">{isEdit ? "Edit PM Schedule" : "Create PM Schedule"}</Typography>
{error && <Alert severity="error">Failed to load PM schedule</Alert>} {Boolean(error) && <Alert severity="error">Failed to load PM schedule</Alert>}
{optionsError && ( {Boolean(optionsError) && (
<Alert severity="error"> <Alert severity="error">
{optionsError instanceof Error ? optionsError.message : "Failed to load form options"} {optionsError instanceof Error ? optionsError.message : "Failed to load form options"}
</Alert> </Alert>

View file

@ -122,7 +122,7 @@ export default function PmSchedulesListPage() {
)} )}
</Stack> </Stack>
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load PM schedules"} {error instanceof Error ? error.message : "Failed to load PM schedules"}
</Alert> </Alert>

View file

@ -158,7 +158,7 @@ export default function DropdownOptionsPage() {
alignItems: "stretch", alignItems: "stretch",
}} }}
> >
{parentCategory && ( {Boolean(parentCategory) && (
<FormControl size="small" className="min-w-[160px]"> <FormControl size="small" className="min-w-[160px]">
<InputLabel id="parent-trade-label">Parent Trade</InputLabel> <InputLabel id="parent-trade-label">Parent Trade</InputLabel>
<Select <Select
@ -201,7 +201,7 @@ export default function DropdownOptionsPage() {
</Button> </Button>
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load options"} {error instanceof Error ? error.message : "Failed to load options"}
</Alert> </Alert>
@ -262,7 +262,7 @@ export default function DropdownOptionsPage() {
<Typography variant="body2" className="truncate"> <Typography variant="body2" className="truncate">
{item.value} {item.value}
</Typography> </Typography>
{item.parentValue && ( {Boolean(item.parentValue) && (
<Chip size="small" label={item.parentValue} variant="outlined" /> <Chip size="small" label={item.parentValue} variant="outlined" />
)} )}
</Stack> </Stack>

View file

@ -126,7 +126,7 @@ export default function TaskTemplatesPage() {
Create reusable checklists for work order dispatches Create reusable checklists for work order dispatches
</Typography> </Typography>
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error" className="mb-4"> <Alert severity="error" className="mb-4">
{error instanceof Error ? error.message : "Failed to load templates"} {error instanceof Error ? error.message : "Failed to load templates"}
</Alert> </Alert>

View file

@ -157,7 +157,7 @@ export default function UpliftQueuePage() {
</FormControl> </FormControl>
</Stack> </Stack>
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load uplift queue"} {error instanceof Error ? error.message : "Failed to load uplift queue"}
</Alert> </Alert>
@ -215,7 +215,7 @@ export default function UpliftQueuePage() {
<TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell> <TableCell>{row.requestedAt ? formatDateTime(row.requestedAt) : ""}</TableCell>
<TableCell> <TableCell>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
{row.workOrderId && ( {Boolean(row.workOrderId) && (
<Button <Button
size="small" size="small"
variant="outlined" variant="outlined"

View file

@ -127,7 +127,7 @@ export default function VendorPoViewPage() {
Dispatch {dispatch.dispatchNumber || "—"} Dispatch {dispatch.dispatchNumber || "—"}
</Typography> </Typography>
<Chip size="small" label={dispatch.status || "—"} /> <Chip size="small" label={dispatch.status || "—"} />
{vendor?.companyName && ( {vendor != null && Boolean(vendor.companyName) && (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
· {vendor.companyName} · {vendor.companyName}
</Typography> </Typography>
@ -177,7 +177,7 @@ export default function VendorPoViewPage() {
<InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow> <InfoRow label="Verified">{formatDateTime(dispatch.verifiedAt)}</InfoRow>
<InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow> <InfoRow label="Email sent">{dispatch.emailSent ? "Yes" : "No"}</InfoRow>
</Box> </Box>
{dispatch.description && ( {Boolean(dispatch.description) && (
<Box className="mt-3"> <Box className="mt-3">
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Description Description
@ -189,7 +189,7 @@ export default function VendorPoViewPage() {
)} )}
</Paper> </Paper>
{dispatch.primaryLocation && ( {dispatch.primaryLocation != null && (
<Paper variant="outlined" className="p-4"> <Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold"> <Typography variant="subtitle1" className="mb-3 font-semibold">
Site Site
@ -277,7 +277,7 @@ export default function VendorPoViewPage() {
</Typography> </Typography>
<Chip size="small" label={u.status} /> <Chip size="small" label={u.status} />
</Stack> </Stack>
{u.vendorReason && ( {Boolean(u.vendorReason) && (
<Typography variant="body2" className="mt-1"> <Typography variant="body2" className="mt-1">
<strong>Reason:</strong> {u.vendorReason} <strong>Reason:</strong> {u.vendorReason}
</Typography> </Typography>

View file

@ -199,7 +199,7 @@ export default function VendorPosListPage() {
Clear filters Clear filters
</Button> </Button>
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load vendor POs"} {error instanceof Error ? error.message : "Failed to load vendor POs"}
</Alert> </Alert>

View file

@ -107,12 +107,12 @@ export function VendorCreateModal({ open, onClose, tradeOptions }: VendorCreateM
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<Stack spacing={2} className="mt-1"> <Stack spacing={2} className="mt-1">
{facetsError && ( {Boolean(facetsError) && (
<Alert severity="warning"> <Alert severity="warning">
Company autocomplete unavailable. You can still type a company manually. Company autocomplete unavailable. You can still type a company manually.
</Alert> </Alert>
)} )}
{submitError && <Alert severity="error">{submitError}</Alert>} {Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
{isLoadingFacets ? ( {isLoadingFacets ? (
<CircularProgress size={24} /> <CircularProgress size={24} />
) : ( ) : (

View file

@ -248,7 +248,7 @@ export function VendorDetailDrawer({
<> <>
{mode === "view" ? ( {mode === "view" ? (
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}> <Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
{submitError && <Alert severity="error">{submitError}</Alert>} {Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}> <Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Chip <Chip
@ -256,7 +256,7 @@ export function VendorDetailDrawer({
label={detail.isActive ? "Active" : "Inactive"} label={detail.isActive ? "Active" : "Inactive"}
color={detail.isActive ? "success" : "default"} color={detail.isActive ? "success" : "default"}
/> />
{trades[0] && ( {Boolean(trades[0]) && (
<Chip <Chip
size="small" size="small"
variant="outlined" variant="outlined"
@ -283,7 +283,7 @@ export function VendorDetailDrawer({
.filter(Boolean) .filter(Boolean)
.join(", ")} .join(", ")}
/> />
{mapsUrl && ( {Boolean(mapsUrl) && (
<Link <Link
href={mapsUrl} href={mapsUrl}
target="_blank" target="_blank"
@ -318,7 +318,7 @@ export function VendorDetailDrawer({
<DetailField label="Total Jobs" value={String(detail.totalJobs ?? 0)} /> <DetailField label="Total Jobs" value={String(detail.totalJobs ?? 0)} />
</DetailSection> </DetailSection>
{detail.notes && ( {Boolean(detail.notes) && (
<DetailSection title="Notes"> <DetailSection title="Notes">
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}> <Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{detail.notes} {detail.notes}
@ -335,7 +335,7 @@ export function VendorDetailDrawer({
sx={{ px: 3, py: 3 }} sx={{ px: 3, py: 3 }}
> >
<Stack spacing={3}> <Stack spacing={3}>
{submitError && <Alert severity="error">{submitError}</Alert>} {Boolean(submitError) && <Alert severity="error">{submitError}</Alert>}
<Controller <Controller
control={control} control={control}
@ -380,7 +380,7 @@ export function VendorDetailDrawer({
> >
{mode === "view" ? ( {mode === "view" ? (
<> <>
{mapsUrl && ( {Boolean(mapsUrl) && (
<Tooltip title="Open in Google Maps"> <Tooltip title="Open in Google Maps">
<Button <Button
component="a" component="a"

View file

@ -118,7 +118,7 @@ export default function VendorFormPage() {
<Stack spacing={4}> <Stack spacing={4}>
<Typography variant="h5">{isEdit ? "Edit Vendor" : "Create Vendor"}</Typography> <Typography variant="h5">{isEdit ? "Edit Vendor" : "Create Vendor"}</Typography>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load vendor"} {error instanceof Error ? error.message : "Failed to load vendor"}
</Alert> </Alert>
@ -146,7 +146,7 @@ export default function VendorFormPage() {
showTotalJobs={isEdit} showTotalJobs={isEdit}
/> />
{isEdit && id && <VendorPortalTokenPanel vendorId={id} />} {isEdit && id != null && <VendorPortalTokenPanel vendorId={id} />}
<Stack <Stack
direction="row" direction="row"

View file

@ -261,7 +261,7 @@ export default function VendorsListPage() {
</Stack> </Stack>
)} )}
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load vendors"} {error instanceof Error ? error.message : "Failed to load vendors"}
</Alert> </Alert>
@ -418,7 +418,7 @@ export default function VendorsListPage() {
<Typography variant="body2" sx={{ color: "text.secondary" }}> <Typography variant="body2" sx={{ color: "text.secondary" }}>
{totalCount} {isActive ? "active" : "inactive"}{" "} {totalCount} {isActive ? "active" : "inactive"}{" "}
{totalCount === 1 ? "technician" : "technicians"} {totalCount === 1 ? "technician" : "technicians"}
{(appliedChips.length > 0 || debouncedSearch) && " · filtered"} {(appliedChips.length > 0 || Boolean(debouncedSearch)) && " · filtered"}
{` · ${activeCount} active · ${inactiveCount} inactive`} {` · ${activeCount} active · ${inactiveCount} inactive`}
</Typography> </Typography>
<TablePagination <TablePagination

View file

@ -542,7 +542,7 @@ export default function WorkOrderViewPage() {
onDispatched={() => void refetch()} onDispatched={() => void refetch()}
/> />
)} )}
{dispatchDetailId && ( {dispatchDetailId != null && (
<DispatchDetailModal <DispatchDetailModal
dispatchId={dispatchDetailId} dispatchId={dispatchDetailId}
workOrderId={workOrderId} workOrderId={workOrderId}

View file

@ -242,7 +242,7 @@ export function DispatchCreateModal({
<Typography variant="body2" className="font-semibold"> <Typography variant="body2" className="font-semibold">
{vendor.companyName} {vendor.companyName}
</Typography> </Typography>
{vendor.tradeSpecialties && ( {Boolean(vendor.tradeSpecialties) && (
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
@ -253,7 +253,7 @@ export function DispatchCreateModal({
{vendor.tradeSpecialties} {vendor.tradeSpecialties}
</Typography> </Typography>
)} )}
{vendor.address && ( {Boolean(vendor.address) && (
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{

View file

@ -331,7 +331,7 @@ export function DispatchDetailModal({
} }
/> />
<DetailField label="Vendor Email" value={dispatch.vendorEmail || "—"} /> <DetailField label="Vendor Email" value={dispatch.vendorEmail || "—"} />
{dispatch.description && ( {Boolean(dispatch.description) && (
<DetailField <DetailField
label="Description" label="Description"
value={dispatch.description} value={dispatch.description}
@ -448,7 +448,7 @@ export function DispatchDetailModal({
<Typography variant="subtitle2">{type}</Typography> <Typography variant="subtitle2">{type}</Typography>
{signoff ? ( {signoff ? (
<Box className="mt-2"> <Box className="mt-2">
{signoff.signatureMethod === "drawn" && signoff.signature && ( {signoff.signatureMethod === "drawn" && Boolean(signoff.signature) && (
<img <img
src={signoff.signature} src={signoff.signature}
alt="Signature" alt="Signature"
@ -527,12 +527,12 @@ export function DispatchDetailModal({
{Number(uplift.delta).toFixed(2)}) {Number(uplift.delta).toFixed(2)})
</Typography> </Typography>
</Stack> </Stack>
{uplift.vendorReason && ( {Boolean(uplift.vendorReason) && (
<Typography variant="body2" className="mt-1"> <Typography variant="body2" className="mt-1">
<strong>Reason:</strong> {uplift.vendorReason} <strong>Reason:</strong> {uplift.vendorReason}
</Typography> </Typography>
)} )}
{(uplift.requestedByVendorName || uplift.requestedAt) && ( {Boolean(uplift.requestedByVendorName || uplift.requestedAt) && (
<Typography <Typography
variant="body2" variant="body2"
className="mt-1" className="mt-1"
@ -545,9 +545,11 @@ export function DispatchDetailModal({
</Typography> </Typography>
)} )}
{uplift.status !== "Pending" && {uplift.status !== "Pending" &&
(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && ( Boolean(
uplift.decidedByName || uplift.decidedAt || uplift.decisionNote,
) && (
<Box className="mt-1"> <Box className="mt-1">
{(uplift.decidedByName || uplift.decidedAt) && ( {Boolean(uplift.decidedByName || uplift.decidedAt) && (
<Typography <Typography
variant="body2" variant="body2"
sx={{ sx={{
@ -558,7 +560,7 @@ export function DispatchDetailModal({
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""} {uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
</Typography> </Typography>
)} )}
{uplift.decisionNote && ( {Boolean(uplift.decisionNote) && (
<Typography variant="body2" className="mt-0.5"> <Typography variant="body2" className="mt-0.5">
<strong>Note:</strong> {uplift.decisionNote} <strong>Note:</strong> {uplift.decisionNote}
</Typography> </Typography>
@ -720,7 +722,7 @@ export function DispatchDetailModal({
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{sigCaptureType && ( {sigCaptureType != null && (
<SignatureCapture <SignatureCapture
title={`${sigCaptureType} Sign-off`} title={`${sigCaptureType} Sign-off`}
onClose={() => setSigCaptureType(null)} onClose={() => setSigCaptureType(null)}

View file

@ -219,7 +219,7 @@ export default function WorkOrdersListPage() {
</Select> </Select>
</FormControl> </FormControl>
</Stack> </Stack>
{error && ( {Boolean(error) && (
<Alert severity="error"> <Alert severity="error">
{error instanceof Error ? error.message : "Failed to load work orders"} {error instanceof Error ? error.message : "Failed to load work orders"}
</Alert> </Alert>

View file

@ -10,7 +10,7 @@ function VendorPortalHeader() {
return ( return (
<div className="vp-header"> <div className="vp-header">
<div className="vp-brand">Sea Haven Industries — Vendor Portal</div> <div className="vp-brand">Sea Haven Industries — Vendor Portal</div>
{status === "ready" && vendor?.companyName && ( {status === "ready" && vendor != null && Boolean(vendor.companyName) && (
<div className="vp-vendor-name">{vendor.companyName}</div> <div className="vp-vendor-name">{vendor.companyName}</div>
)} )}
</div> </div>
@ -56,13 +56,13 @@ function VendorPortalBody() {
if (status === "error") { if (status === "error") {
return ( return (
<div className="vp-container"> <div className="vp-container">
<section className="vp-error"> <Text as="section" variant="body" className="vp-error">
<Text variant="title">Access Denied</Text> <Text variant="title">Access Denied</Text>
<Text variant="error"> <Text variant="error">
{error?.message || {error?.message ||
"This link is invalid or has expired. Please contact your dispatcher for a new link."} "This link is invalid or has expired. Please contact your dispatcher for a new link."}
</Text> </Text>
</section> </Text>
</div> </div>
); );
} }

View file

@ -64,7 +64,7 @@ function ChecklistSection({
<label htmlFor={`cl-${item.id}`} style={{ flex: 1 }}> <label htmlFor={`cl-${item.id}`} style={{ flex: 1 }}>
{item.itemText} {item.itemText}
</label> </label>
{item.isCompleted && item.completedAt && ( {item.isCompleted && Boolean(item.completedAt) && (
<span className="vp-muted" style={{ fontSize: 12 }}> <span className="vp-muted" style={{ fontSize: 12 }}>
{formatVendorPortalDateTime(item.completedAt)} {formatVendorPortalDateTime(item.completedAt)}
</span> </span>
@ -225,7 +225,7 @@ function UpliftRequestsSection({
</span> </span>
</span> </span>
</div> </div>
{request.vendorReason && ( {Boolean(request.vendorReason) && (
<div className="vp-row"> <div className="vp-row">
<strong>Reason</strong> <strong>Reason</strong>
<span>{request.vendorReason}</span> <span>{request.vendorReason}</span>
@ -469,13 +469,13 @@ export default function VendorPortalDispatchPage() {
</div> </div>
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
{data.workOrder?.internalWONumber && ( {data.workOrder != null && Boolean(data.workOrder.internalWONumber) && (
<div className="vp-row"> <div className="vp-row">
<strong>WO #</strong> <strong>WO #</strong>
<span>{data.workOrder.internalWONumber}</span> <span>{data.workOrder.internalWONumber}</span>
</div> </div>
)} )}
{data.poNumber && ( {Boolean(data.poNumber) && (
<div className="vp-row"> <div className="vp-row">
<strong>PO #</strong> <strong>PO #</strong>
<span>{data.poNumber}</span> <span>{data.poNumber}</span>
@ -487,7 +487,7 @@ export default function VendorPortalDispatchPage() {
<span>${Number(data.nteAmount).toFixed(2)}</span> <span>${Number(data.nteAmount).toFixed(2)}</span>
</div> </div>
)} )}
{data.scheduledDate && ( {Boolean(data.scheduledDate) && (
<div className="vp-row"> <div className="vp-row">
<strong>Scheduled</strong> <strong>Scheduled</strong>
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span> <span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
@ -495,7 +495,7 @@ export default function VendorPortalDispatchPage() {
)} )}
</div> </div>
{data.location && ( {data.location != null && (
<> <>
<Text variant="heading" sx={{ marginTop: 2 }}> <Text variant="heading" sx={{ marginTop: 2 }}>
Location Location
@ -514,7 +514,7 @@ export default function VendorPortalDispatchPage() {
</> </>
)} )}
{(data.description || data.workOrder?.description) && ( {Boolean(data.description || data.workOrder?.description) && (
<> <>
<Text variant="heading" sx={{ marginTop: 2 }}> <Text variant="heading" sx={{ marginTop: 2 }}>
Description Description

View file

@ -22,7 +22,7 @@ export const EventCalendar = forwardRef<FullCalendar, EventCalendarProps>(functi
) { ) {
return ( return (
<Box className="relative rounded-lg border border-border bg-card p-3"> <Box className="relative rounded-lg border border-border bg-card p-3">
{loading && ( {Boolean(loading) && (
<Box className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-white/70"> <Box className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-white/70">
<CircularProgress size={32} /> <CircularProgress size={32} />
</Box> </Box>

View file

@ -185,7 +185,7 @@ export function SignatureCapture({ onClose, onSave, title }: SignatureCapturePro
fullWidth fullWidth
size="small" size="small"
/> />
{typedName && ( {Boolean(typedName) && (
<Typography <Typography
variant="h5" variant="h5"
className="rounded border border-dashed border-border px-4 py-6 text-center italic" className="rounded border border-dashed border-border px-4 py-6 text-center italic"

View file

@ -32,7 +32,7 @@ export function CollapsibleSection({
aria-expanded={open} aria-expanded={open}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{Icon && <Icon size={16} className="text-primary" />} {Icon != null && <Icon size={16} className="text-primary" />}
<span className="text-sm font-semibold text-foreground">{label}</span> <span className="text-sm font-semibold text-foreground">{label}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -1,13 +1,13 @@
import type { ReactNode } from "react"; import type { ReactElement } from "react";
import { Box, Typography } from "@mui/material"; import { Box, Typography } from "@mui/material";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type EmptyStateProps = { type EmptyStateProps = {
icon?: ReactNode; icon?: ReactElement;
title: string; title: string;
description?: string; description?: string;
action?: ReactNode; action?: ReactElement;
className?: string; className?: string;
}; };
@ -19,11 +19,11 @@ export function EmptyState({ icon, title, description, action, className }: Empt
className, className,
)} )}
> >
{icon && <Box className="text-muted-foreground">{icon}</Box>} {Boolean(icon) && <Box className="text-muted-foreground">{icon}</Box>}
<Typography variant="subtitle1" className="text-foreground"> <Typography variant="subtitle1" className="text-foreground">
{title} {title}
</Typography> </Typography>
{description && ( {Boolean(description) && (
<Typography <Typography
variant="body2" variant="body2"
className="max-w-sm" className="max-w-sm"
@ -34,7 +34,7 @@ export function EmptyState({ icon, title, description, action, className }: Empt
{description} {description}
</Typography> </Typography>
)} )}
{action && <Box className="mt-2">{action}</Box>} {Boolean(action) && <Box className="mt-2">{action}</Box>}
</Box> </Box>
); );
} }

View file

@ -34,7 +34,7 @@ export function FilterPopover({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-semibold text-foreground">Filters</span> <span className="text-xs font-semibold text-foreground">Filters</span>
{activeCount > 0 && onClear && ( {activeCount > 0 && Boolean(onClear) && (
<button <button
type="button" type="button"
onClick={onClear} onClick={onClear}

View file

@ -64,7 +64,7 @@ export function FormField({
{label} {label}
</FormLabel> </FormLabel>
{children} {children}
{helperText && <FormHelperText>{helperText}</FormHelperText>} {Boolean(helperText) && <FormHelperText>{helperText}</FormHelperText>}
</FormControl> </FormControl>
); );
} }

View file

@ -1,4 +1,4 @@
import type { ReactNode } from "react"; import type { ReactElement } from "react";
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
@ -8,7 +8,7 @@ type PageHeaderProps = {
eyebrow?: string; eyebrow?: string;
title: string; title: string;
subtitle?: string; subtitle?: string;
actions?: ReactNode; actions?: ReactElement;
className?: string; className?: string;
/** White surface bar (list page headers). */ /** White surface bar (list page headers). */
surface?: boolean; surface?: boolean;
@ -56,7 +56,7 @@ export function PageHeader({
{subtitle} {subtitle}
</Text> </Text>
</Stack> </Stack>
{actions && ( {Boolean(actions) && (
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "nowrap" }}> <Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "nowrap" }}>
{actions} {actions}
</Stack> </Stack>

View file

@ -19,7 +19,10 @@ type TextVariant =
type TextTone = "default" | "muted" | "error" | "success" | "warning"; type TextTone = "default" | "muted" | "error" | "success" | "warning";
type TextFamily = "display" | "body" | "mono"; type TextFamily = "display" | "body" | "mono";
export type TextProps = Omit<MuiTypographyProps, "children" | "color" | "variant"> & { export type TextProps = Omit<
MuiTypographyProps,
"children" | "color" | "variant" | "component" | "role" | "aria-live" | "aria-atomic"
> & {
children: ReactNode; children: ReactNode;
as?: ElementType; as?: ElementType;
family?: TextFamily; family?: TextFamily;
@ -68,7 +71,9 @@ export function Text({
sx, sx,
...props ...props
}: TextProps) { }: TextProps) {
if (!when) { const persistRegion = variant === "feedback";
if (!when && !persistRegion) {
return null; return null;
} }
@ -76,13 +81,14 @@ export function Text({
const resolvedTone = tone ?? (variant === "error" ? "error" : "default"); const resolvedTone = tone ?? (variant === "error" ? "error" : "default");
let liveProps = {}; let liveProps = {};
if (variant === "error") { if (variant === "error") {
liveProps = { role: "alert", "aria-live": "assertive" as const }; liveProps = { role: "alert", "aria-live": "assertive" as const, "aria-atomic": true as const };
} else if (variant === "feedback") { } else if (variant === "feedback") {
liveProps = { role: "status", "aria-live": "polite" as const }; liveProps = { role: "status", "aria-live": "polite" as const, "aria-atomic": true as const };
} }
return ( return (
<MuiTypography <MuiTypography
{...props}
component={as ?? config.element} component={as ?? config.element}
variant={config.muiVariant} variant={config.muiVariant}
color={toneColor[resolvedTone]} color={toneColor[resolvedTone]}
@ -91,9 +97,8 @@ export function Text({
...(Array.isArray(sx) ? sx : [sx]), ...(Array.isArray(sx) ? sx : [sx]),
]} ]}
{...liveProps} {...liveProps}
{...props}
> >
{children} {when && children}
</MuiTypography> </MuiTypography>
); );
} }

View file

@ -4,28 +4,28 @@ import type { RouteObject } from "react-router";
import type { RouteModule } from "@/routing/route-module"; import type { RouteModule } from "@/routing/route-module";
function Modals({ modalRoutes }: { modalRoutes: Record<string, ComponentType> }) {
const modalPath = useLocation().state?.modal as string | undefined;
const Modal = modalPath ? modalRoutes[modalPath] : undefined;
if (!Modal) {
return null;
}
return <Modal />;
}
export function createAppShellRoute( export function createAppShellRoute(
appLayout: RouteModule | undefined, appLayout: RouteModule | undefined,
modalRoutes: Record<string, ComponentType>, modalRoutes: Record<string, ComponentType>,
): Pick<RouteObject, "Component" | "ErrorBoundary" | "loader"> { ): Pick<RouteObject, "Component" | "ErrorBoundary" | "loader"> {
const AppLayout = appLayout?.default ?? Outlet; const AppLayout = appLayout?.default ?? Outlet;
function Modals() {
const modalPath = useLocation().state?.modal as string | undefined;
const Modal = modalPath ? modalRoutes[modalPath] : undefined;
if (!Modal) {
return null;
}
return <Modal />;
}
function Layout() { function Layout() {
return ( return (
<> <>
<AppLayout /> <AppLayout />
<Modals modalRoutes={modalRoutes} /> <Modals />
</> </>
); );
} }

View file

@ -20,7 +20,7 @@ describe("Text", () => {
it("does not render conditional text when its condition is false", () => { it("does not render conditional text when its condition is false", () => {
renderWithProviders( renderWithProviders(
<Text when={false} variant="feedback"> <Text when={false} variant="body">
Saved Saved
</Text>, </Text>,
{ withAuth: false }, { withAuth: false },
@ -29,6 +29,29 @@ describe("Text", () => {
expect(screen.queryByText("Saved")).not.toBeInTheDocument(); expect(screen.queryByText("Saved")).not.toBeInTheDocument();
}); });
it("keeps the feedback live region mounted so toggling its content is announceable", () => {
const { rerender } = renderWithProviders(
<Text variant="feedback" when={false}>
Saved
</Text>,
{ withAuth: false },
);
const region = screen.getByRole("status");
expect(region).toHaveAttribute("aria-live", "polite");
expect(region).toHaveAttribute("aria-atomic", "true");
expect(region).toBeEmptyDOMElement();
rerender(
<Text variant="feedback" when={true}>
Saved
</Text>,
);
expect(screen.getByRole("status")).toBe(region);
expect(region).toHaveTextContent("Saved");
});
it("gives error and feedback messages accessible live-region semantics", () => { it("gives error and feedback messages accessible live-region semantics", () => {
renderWithProviders( renderWithProviders(
<> <>
@ -38,7 +61,22 @@ describe("Text", () => {
{ withAuth: false }, { withAuth: false },
); );
expect(screen.getByRole("alert")).toHaveTextContent("Upload failed"); const alert = screen.getByRole("alert");
expect(screen.getByRole("status")).toHaveTextContent("Uploading"); const status = screen.getByRole("status");
expect(alert).toHaveTextContent("Upload failed");
expect(status).toHaveTextContent("Uploading");
expect(alert).toHaveAttribute("aria-live", "assertive");
expect(alert).toHaveAttribute("aria-atomic", "true");
expect(status).toHaveAttribute("aria-live", "polite");
expect(status).toHaveAttribute("aria-atomic", "true");
});
it("does not attach live-region semantics to non-feedback variants", () => {
renderWithProviders(<Text variant="body">Saved</Text>, { withAuth: false });
const node = screen.getByText("Saved");
expect(node).not.toHaveAttribute("role");
expect(node).not.toHaveAttribute("aria-live");
expect(node).not.toHaveAttribute("aria-atomic");
}); });
}); });