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
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
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;
- 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,
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
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 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 = [
"src/pages/**",
"src/app/store.js",
@ -54,6 +62,7 @@ export default tseslint.config(
plugins: {
"react-hooks": pluginReactHooks,
"react-refresh": pluginReactRefresh,
seahaven: localRules,
},
rules: {
...pluginReactHooks.configs.recommended.rules,
@ -62,6 +71,7 @@ export default tseslint.config(
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
"seahaven/no-non-boolean-jsx-and": "error",
"no-restricted-syntax": [
"error",
{
@ -78,8 +88,9 @@ export default tseslint.config(
},
{
selector:
"JSXOpeningElement[name.name='div'] > JSXAttribute[name.name='className'][value.value='vp-error']",
message: 'Use Text variant="error" for accessible, consistent error feedback.',
"JSXOpeningElement:not([name.name='Text']) > JSXAttribute[name.name='className'][value.type='Literal'][value.value=/vp-error/]",
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:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0",
"format": "prettier --write .",
"format:check": "prettier --check .",
"prepare": "husky"

View file

@ -75,7 +75,9 @@ export function LoginForm({
disabled={isLoggingIn}
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>
<Button

View file

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

View file

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

View file

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

View file

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

View file

@ -120,7 +120,7 @@ export default function EventFormPage() {
<Stack spacing={3}>
<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)}>
<Stack spacing={3}>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,7 +10,7 @@ function VendorPortalHeader() {
return (
<div className="vp-header">
<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>
@ -56,13 +56,13 @@ function VendorPortalBody() {
if (status === "error") {
return (
<div className="vp-container">
<section className="vp-error">
<Text as="section" variant="body" className="vp-error">
<Text variant="title">Access Denied</Text>
<Text variant="error">
{error?.message ||
"This link is invalid or has expired. Please contact your dispatcher for a new link."}
</Text>
</section>
</Text>
</div>
);
}

View file

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

View file

@ -22,7 +22,7 @@ export const EventCalendar = forwardRef<FullCalendar, EventCalendarProps>(functi
) {
return (
<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">
<CircularProgress size={32} />
</Box>

View file

@ -185,7 +185,7 @@ export function SignatureCapture({ onClose, onSave, title }: SignatureCapturePro
fullWidth
size="small"
/>
{typedName && (
{Boolean(typedName) && (
<Typography
variant="h5"
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}
>
<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>
</div>
<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 { cn } from "@/lib/utils";
type EmptyStateProps = {
icon?: ReactNode;
icon?: ReactElement;
title: string;
description?: string;
action?: ReactNode;
action?: ReactElement;
className?: string;
};
@ -19,11 +19,11 @@ export function EmptyState({ icon, title, description, action, className }: Empt
className,
)}
>
{icon && <Box className="text-muted-foreground">{icon}</Box>}
{Boolean(icon) && <Box className="text-muted-foreground">{icon}</Box>}
<Typography variant="subtitle1" className="text-foreground">
{title}
</Typography>
{description && (
{Boolean(description) && (
<Typography
variant="body2"
className="max-w-sm"
@ -34,7 +34,7 @@ export function EmptyState({ icon, title, description, action, className }: Empt
{description}
</Typography>
)}
{action && <Box className="mt-2">{action}</Box>}
{Boolean(action) && <Box className="mt-2">{action}</Box>}
</Box>
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -4,28 +4,28 @@ import type { RouteObject } from "react-router";
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(
appLayout: RouteModule | undefined,
modalRoutes: Record<string, ComponentType>,
): Pick<RouteObject, "Component" | "ErrorBoundary" | "loader"> {
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() {
return (
<>
<AppLayout />
<Modals modalRoutes={modalRoutes} />
<Modals />
</>
);
}

View file

@ -20,7 +20,7 @@ describe("Text", () => {
it("does not render conditional text when its condition is false", () => {
renderWithProviders(
<Text when={false} variant="feedback">
<Text when={false} variant="body">
Saved
</Text>,
{ withAuth: false },
@ -29,6 +29,29 @@ describe("Text", () => {
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", () => {
renderWithProviders(
<>
@ -38,7 +61,22 @@ describe("Text", () => {
{ withAuth: false },
);
expect(screen.getByRole("alert")).toHaveTextContent("Upload failed");
expect(screen.getByRole("status")).toHaveTextContent("Uploading");
const alert = screen.getByRole("alert");
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");
});
});