mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 23:53:21 +00:00
chore(frontend): enforce maintainable text rendering
This commit is contained in:
parent
20237f8142
commit
21b7de57a1
11 changed files with 297 additions and 39 deletions
66
docs/FRONTEND_MAINTAINABILITY.md
Normal file
66
docs/FRONTEND_MAINTAINABILITY.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Frontend maintainability conventions
|
||||
|
||||
## Conditional rendering
|
||||
|
||||
Use logical `&&` or the `when` prop on `Text` when JSX has only a rendered state and an empty
|
||||
state. Use a ternary only when both branches render meaningful alternatives.
|
||||
|
||||
```tsx
|
||||
{
|
||||
error && <Alert severity="error">{error.message}</Alert>;
|
||||
}
|
||||
|
||||
<Text variant="description" when={Boolean(description)}>
|
||||
{description}
|
||||
</Text>;
|
||||
```
|
||||
|
||||
ESLint rejects `condition ? <Element /> : null`. This keeps one-sided conditions visually
|
||||
distinct from real either-or UI decisions.
|
||||
|
||||
## Typography and feedback
|
||||
|
||||
Use `Text` from `@/components/ui/text` for headings, paragraphs, descriptions, labels, captions,
|
||||
code, and asynchronous feedback. It owns:
|
||||
|
||||
- semantic HTML for each visual variant;
|
||||
- the display, body, and monospace font families;
|
||||
- default, muted, error, success, and warning tones;
|
||||
- accessible `alert` and `status` live regions for error and feedback text;
|
||||
- one-sided conditional text through `when`.
|
||||
|
||||
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.
|
||||
|
||||
## Forms and mutations
|
||||
|
||||
Use the libraries already established in the application:
|
||||
|
||||
- React Hook Form owns field registration, touched/dirty state, and client form lifecycle.
|
||||
- Zod owns form validation and inferred form value types.
|
||||
- TanStack Query owns server reads and mutations, including pending/error state, cache
|
||||
invalidation, and retries where safe.
|
||||
|
||||
Do not add TanStack Form alongside React Hook Form. It would create two form conventions without
|
||||
removing any current dependency. Reconsider only as a deliberate repository-wide migration with
|
||||
benchmarks, a codemod plan, and an approved deprecation path.
|
||||
|
||||
File uploads are not ordinary form fields. Keep file selection and client validation in a focused
|
||||
component, and use a TanStack Query mutation for upload progress, errors, completion refresh, and
|
||||
retry state. Do not place upload orchestration in a route-sized page component.
|
||||
|
||||
## Page state
|
||||
|
||||
Pages should compose focused state components instead of accumulating unrelated booleans:
|
||||
|
||||
- query loading, error, and empty states stay adjacent to the query result;
|
||||
- mutation pending/error state belongs to the component that initiated the mutation;
|
||||
- route pages coordinate sections and navigation;
|
||||
- reusable sections own their interaction details;
|
||||
- errors render inline with accessible feedback, with toasts reserved for cross-page outcomes.
|
||||
|
||||
## Enforcement and rollout
|
||||
|
||||
The lint rules are repository-wide and the initial violations were migrated in the same change.
|
||||
`npm run lint`, `npm run build`, and the `Text` behavior tests are required gates. Future
|
||||
maintainability rules must also land with a green migration rather than a warning-only backlog.
|
||||
|
|
@ -62,6 +62,26 @@ 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",
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector:
|
||||
"JSXExpressionContainer > ConditionalExpression[alternate.type='Literal'][alternate.value=null]",
|
||||
message:
|
||||
"Use logical AND for one-sided JSX rendering instead of `condition ? element : null`.",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
":matches(JSXOpeningElement[name.name='p'], JSXOpeningElement[name.name='h1'], JSXOpeningElement[name.name='h2'], JSXOpeningElement[name.name='h3'], JSXOpeningElement[name.name='h4'], JSXOpeningElement[name.name='h5'], JSXOpeningElement[name.name='h6'])",
|
||||
message:
|
||||
"Use the shared Text component so typography semantics, family, tone, and feedback behavior stay consistent.",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"JSXOpeningElement[name.name='div'] > JSXAttribute[name.name='className'][value.value='vp-error']",
|
||||
message: 'Use Text variant="error" for accessible, consistent error feedback.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
|
||||
|
|
@ -10,12 +11,17 @@ export type AuthCardHeaderProps = ComponentPropsWithoutRef<"div"> & {
|
|||
export function AuthCardHeader({ title, subtitle, className, ...props }: AuthCardHeaderProps) {
|
||||
return (
|
||||
<div className={cn(className)} {...props}>
|
||||
<h2 className="m-0 text-center font-display font-bold text-foreground">{title}</h2>
|
||||
{subtitle && (
|
||||
<p className="mb-6! mt-1.5! text-center font-sans font-normal text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
<Text variant="title" className="m-0 text-center font-bold text-foreground">
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
variant="description"
|
||||
tone="muted"
|
||||
when={Boolean(subtitle)}
|
||||
className="mb-6! mt-1.5! text-center font-normal"
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
|
|||
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
|
||||
import { LoginForm } from "@/app/(auth)/_components/login-form";
|
||||
import { BrandLockup } from "@/components/common/brand-lockup";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
||||
|
|
@ -37,9 +38,13 @@ export default function LoginPage() {
|
|||
isLoggingIn={isLoggingIn}
|
||||
loginError={loginError}
|
||||
/>
|
||||
<p className="mt-5! block text-center font-sans text-(length:--text-xs) text-muted-foreground">
|
||||
<Text
|
||||
variant="caption"
|
||||
tone="muted"
|
||||
className="mt-5! block text-center text-(length:--text-xs)"
|
||||
>
|
||||
Having trouble? Contact your administrator.
|
||||
</p>
|
||||
</Text>
|
||||
</AuthPageShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { NavLink, Outlet, useLocation, useParams } from "react-router";
|
||||
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
|
||||
import { VendorPortalProvider } from "@/app/v/_components/vendor-portal-provider";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import "@/app/v/_components/vendor-portal.css";
|
||||
|
||||
function VendorPortalHeader() {
|
||||
|
|
@ -55,13 +56,13 @@ function VendorPortalBody() {
|
|||
if (status === "error") {
|
||||
return (
|
||||
<div className="vp-container">
|
||||
<div className="vp-error">
|
||||
<h2>Access Denied</h2>
|
||||
<p>
|
||||
<section 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."}
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
|||
import { useNavigate } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import {
|
||||
formatVendorPortalDateTime,
|
||||
|
|
@ -54,7 +55,7 @@ export default function VendorPortalDashboardPage() {
|
|||
return (
|
||||
<>
|
||||
<div className="vp-card">
|
||||
<h2>Your Work</h2>
|
||||
<Text variant="title">Your Work</Text>
|
||||
<div className="vp-muted" style={{ marginBottom: 12 }}>
|
||||
Active dispatches and work orders assigned to your company.
|
||||
</div>
|
||||
|
|
@ -76,7 +77,9 @@ export default function VendorPortalDashboardPage() {
|
|||
</div>
|
||||
|
||||
{isLoading && <div className="vp-card vp-muted">Loading dispatches…</div>}
|
||||
{error && <div className="vp-error">{error.message}</div>}
|
||||
<Text variant="error" when={Boolean(error)} className="vp-error">
|
||||
{error?.message}
|
||||
</Text>
|
||||
|
||||
{!isLoading && !error && filtered.length === 0 && (
|
||||
<div className="vp-card vp-empty">No dispatches in this view.</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Link, useParams } from "react-router";
|
|||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { SignaturePad } from "@/app/v/_components/signature-pad";
|
||||
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import {
|
||||
formatVendorPortalDateTime,
|
||||
|
|
@ -429,7 +430,11 @@ export default function VendorPortalDispatchPage() {
|
|||
}
|
||||
|
||||
if (error && !data) {
|
||||
return <div className="vp-error">{error.message}</div>;
|
||||
return (
|
||||
<Text variant="error" className="vp-error">
|
||||
{error.message}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
|
@ -457,7 +462,7 @@ export default function VendorPortalDispatchPage() {
|
|||
<div className="vp-card">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<h2>{data.dispatchNumber}</h2>
|
||||
<Text variant="title">{data.dispatchNumber}</Text>
|
||||
<div className="vp-muted">{data.workOrder?.workerOrderTitle}</div>
|
||||
</div>
|
||||
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
|
||||
|
|
@ -492,7 +497,9 @@ export default function VendorPortalDispatchPage() {
|
|||
|
||||
{data.location && (
|
||||
<>
|
||||
<h3 style={{ marginTop: 16 }}>Location</h3>
|
||||
<Text variant="heading" sx={{ marginTop: 2 }}>
|
||||
Location
|
||||
</Text>
|
||||
<div>{data.location.name}</div>
|
||||
<div className="vp-muted">
|
||||
{[
|
||||
|
|
@ -509,7 +516,9 @@ export default function VendorPortalDispatchPage() {
|
|||
|
||||
{(data.description || data.workOrder?.description) && (
|
||||
<>
|
||||
<h3 style={{ marginTop: 16 }}>Description</h3>
|
||||
<Text variant="heading" sx={{ marginTop: 2 }}>
|
||||
Description
|
||||
</Text>
|
||||
<div>{data.description || data.workOrder?.description}</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -570,7 +579,7 @@ export default function VendorPortalDispatchPage() {
|
|||
</div>
|
||||
|
||||
<div className="vp-card">
|
||||
<h3>Checklist</h3>
|
||||
<Text variant="heading">Checklist</Text>
|
||||
<ChecklistSection
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
|
|
@ -588,7 +597,7 @@ export default function VendorPortalDispatchPage() {
|
|||
</div>
|
||||
|
||||
<div className="vp-card">
|
||||
<h3>NTE Uplift Requests</h3>
|
||||
<Text variant="heading">NTE Uplift Requests</Text>
|
||||
<UpliftRequestsSection
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
|
|
@ -600,7 +609,7 @@ export default function VendorPortalDispatchPage() {
|
|||
</div>
|
||||
|
||||
<div className="vp-card">
|
||||
<h3>Customer Signoff</h3>
|
||||
<Text variant="heading">Customer Signoff</Text>
|
||||
<SignoffSection
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
|
|
@ -612,7 +621,7 @@ export default function VendorPortalDispatchPage() {
|
|||
</div>
|
||||
|
||||
<div className="vp-card">
|
||||
<h3>Vendor Signoff</h3>
|
||||
<Text variant="heading">Vendor Signoff</Text>
|
||||
<SignoffSection
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
|
|
@ -624,7 +633,7 @@ export default function VendorPortalDispatchPage() {
|
|||
</div>
|
||||
|
||||
<div className="vp-card">
|
||||
<h3>Comments</h3>
|
||||
<Text variant="heading">Comments</Text>
|
||||
<CommentsSection
|
||||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
|||
import { useNavigate } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import {
|
||||
formatVendorPortalDateTime,
|
||||
|
|
@ -81,7 +82,7 @@ export default function VendorPortalPosPage() {
|
|||
return (
|
||||
<>
|
||||
<div className="vp-card">
|
||||
<h2>Your Purchase Orders</h2>
|
||||
<Text variant="title">Your Purchase Orders</Text>
|
||||
<div className="vp-muted" style={{ marginBottom: 12 }}>
|
||||
Every PO issued to your company, with current status and dollar value.
|
||||
</div>
|
||||
|
|
@ -113,7 +114,9 @@ export default function VendorPortalPosPage() {
|
|||
</div>
|
||||
|
||||
{isLoading && <div className="vp-card vp-muted">Loading POs…</div>}
|
||||
{error && <div className="vp-error">{error.message}</div>}
|
||||
<Text variant="error" when={Boolean(error)} className="vp-error">
|
||||
{error?.message}
|
||||
</Text>
|
||||
|
||||
{!isLoading && !error && visible.length === 0 && (
|
||||
<div className="vp-card vp-empty">No POs match this view.</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import { Stack } from "@mui/material";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PageHeaderProps = {
|
||||
|
|
@ -35,24 +36,25 @@ export function PageHeader({
|
|||
}}
|
||||
>
|
||||
<Stack spacing={0}>
|
||||
{eyebrow && (
|
||||
<p className="m-0 font-sans text-(length:--text-xs) font-bold leading-none tracking-widest text-primary uppercase">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h1
|
||||
<Text
|
||||
variant="label"
|
||||
when={Boolean(eyebrow)}
|
||||
className="m-0 text-(length:--text-xs) font-bold leading-none tracking-widest text-primary uppercase"
|
||||
>
|
||||
{eyebrow}
|
||||
</Text>
|
||||
<Text
|
||||
variant="display"
|
||||
className={cn(
|
||||
"m-0 font-display mt-1! text-[30px]! font-extrabold leading-[1.1] tracking-[-0.02em] text-foreground",
|
||||
eyebrow && "mt-1",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Text>
|
||||
<Text variant="description" tone="muted" when={Boolean(subtitle)}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</Stack>
|
||||
{actions && (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "nowrap" }}>
|
||||
|
|
|
|||
99
src/components/ui/text.tsx
Normal file
99
src/components/ui/text.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { ElementType, ReactNode } from "react";
|
||||
import {
|
||||
Typography as MuiTypography,
|
||||
type TypographyProps as MuiTypographyProps,
|
||||
} from "@mui/material";
|
||||
|
||||
type TextVariant =
|
||||
| "display"
|
||||
| "title"
|
||||
| "heading"
|
||||
| "body"
|
||||
| "description"
|
||||
| "label"
|
||||
| "feedback"
|
||||
| "error"
|
||||
| "caption"
|
||||
| "code";
|
||||
|
||||
type TextTone = "default" | "muted" | "error" | "success" | "warning";
|
||||
type TextFamily = "display" | "body" | "mono";
|
||||
|
||||
export type TextProps = Omit<MuiTypographyProps, "children" | "color" | "variant"> & {
|
||||
children: ReactNode;
|
||||
as?: ElementType;
|
||||
family?: TextFamily;
|
||||
tone?: TextTone;
|
||||
variant?: TextVariant;
|
||||
when?: boolean;
|
||||
};
|
||||
|
||||
const variantConfig: Record<
|
||||
TextVariant,
|
||||
{ element: ElementType; muiVariant: MuiTypographyProps["variant"]; family: TextFamily }
|
||||
> = {
|
||||
display: { element: "h1", muiVariant: "h3", family: "display" },
|
||||
title: { element: "h2", muiVariant: "h5", family: "display" },
|
||||
heading: { element: "h3", muiVariant: "h6", family: "display" },
|
||||
body: { element: "p", muiVariant: "body1", family: "body" },
|
||||
description: { element: "p", muiVariant: "body2", family: "body" },
|
||||
label: { element: "span", muiVariant: "subtitle2", family: "body" },
|
||||
feedback: { element: "p", muiVariant: "body2", family: "body" },
|
||||
error: { element: "p", muiVariant: "body2", family: "body" },
|
||||
caption: { element: "span", muiVariant: "caption", family: "body" },
|
||||
code: { element: "code", muiVariant: "body2", family: "mono" },
|
||||
};
|
||||
|
||||
const toneColor: Record<TextTone, MuiTypographyProps["color"]> = {
|
||||
default: "text.primary",
|
||||
muted: "text.secondary",
|
||||
error: "error",
|
||||
success: "success.main",
|
||||
warning: "warning.main",
|
||||
};
|
||||
|
||||
const familyValue: Record<TextFamily, string> = {
|
||||
display: "var(--font-display)",
|
||||
body: "var(--font-sans)",
|
||||
mono: "var(--font-mono)",
|
||||
};
|
||||
|
||||
export function Text({
|
||||
as,
|
||||
children,
|
||||
family,
|
||||
tone,
|
||||
variant = "body",
|
||||
when = true,
|
||||
sx,
|
||||
...props
|
||||
}: TextProps) {
|
||||
if (!when) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = variantConfig[variant];
|
||||
const resolvedTone = tone ?? (variant === "error" ? "error" : "default");
|
||||
let liveProps = {};
|
||||
if (variant === "error") {
|
||||
liveProps = { role: "alert", "aria-live": "assertive" as const };
|
||||
} else if (variant === "feedback") {
|
||||
liveProps = { role: "status", "aria-live": "polite" as const };
|
||||
}
|
||||
|
||||
return (
|
||||
<MuiTypography
|
||||
component={as ?? config.element}
|
||||
variant={config.muiVariant}
|
||||
color={toneColor[resolvedTone]}
|
||||
sx={[
|
||||
{ fontFamily: familyValue[family ?? config.family] },
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
{...liveProps}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</MuiTypography>
|
||||
);
|
||||
}
|
||||
44
src/test/components/ui/text.test.tsx
Normal file
44
src/test/components/ui/text.test.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
describe("Text", () => {
|
||||
it("maps visual variants to semantic elements", () => {
|
||||
renderWithProviders(
|
||||
<>
|
||||
<Text variant="display">Page title</Text>
|
||||
<Text variant="description">Supporting copy</Text>
|
||||
</>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Page title" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Supporting copy").tagName).toBe("P");
|
||||
});
|
||||
|
||||
it("does not render conditional text when its condition is false", () => {
|
||||
renderWithProviders(
|
||||
<Text when={false} variant="feedback">
|
||||
Saved
|
||||
</Text>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Saved")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives error and feedback messages accessible live-region semantics", () => {
|
||||
renderWithProviders(
|
||||
<>
|
||||
<Text variant="error">Upload failed</Text>
|
||||
<Text variant="feedback">Uploading</Text>
|
||||
</>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Upload failed");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Uploading");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue