mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 05:43:12 +00:00
refactor(ui): enforce maintainable conditional feedback
This commit is contained in:
parent
21b7de57a1
commit
392af77b91
47 changed files with 308 additions and 106 deletions
|
|
@ -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:
|
||||
|
|
|
|||
95
eslint-rules/no-non-boolean-jsx-and.js
Normal file
95
eslint-rules/no-non-boolean-jsx-and.js
Normal 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;
|
||||
|
|
@ -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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
4
src/app/(protected)/vendors/index.tsx
vendored
4
src/app/(protected)/vendors/index.tsx
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -542,7 +542,7 @@ export default function WorkOrderViewPage() {
|
|||
onDispatched={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
{dispatchDetailId && (
|
||||
{dispatchDetailId != null && (
|
||||
<DispatchDetailModal
|
||||
dispatchId={dispatchDetailId}
|
||||
workOrderId={workOrderId}
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export function FormField({
|
|||
{label}
|
||||
</FormLabel>
|
||||
{children}
|
||||
{helperText && <FormHelperText>{helperText}</FormHelperText>}
|
||||
{Boolean(helperText) && <FormHelperText>{helperText}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue