@terpjs/react-core 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -22
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +35 -75
- package/src/ModuleNav.test.tsx +26 -0
- package/src/ModuleNav.tsx +45 -38
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +37 -39
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +9 -36
- package/src/ResourceList.tsx +13 -24
- package/src/SettingsPage.tsx +50 -0
- package/src/SplitPage.tsx +150 -0
- package/src/UserMenu.test.tsx +28 -5
- package/src/UserMenu.tsx +15 -9
- package/src/admin/AuditLogAdmin.tsx +21 -16
- package/src/admin/GroupCreate.tsx +18 -4
- package/src/admin/GroupDetail.tsx +50 -15
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +41 -12
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +238 -3
- package/src/admin/fieldErrors.ts +45 -0
- package/src/bootstrap.test.tsx +208 -0
- package/src/bootstrap.tsx +121 -5
- package/src/breakpoints.ts +41 -0
- package/src/dataview/DataView.tsx +12 -5
- package/src/dataview/DataViewCardList.tsx +8 -7
- package/src/dataview/DataViewPagination.tsx +15 -8
- package/src/dataview/DataViewTable.tsx +32 -21
- package/src/dataview/README.md +13 -2
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +31 -1
- package/src/dataview/types.ts +26 -3
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/files.tsx +2 -11
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +63 -7
- package/src/layout.manifest.json +118 -0
- package/src/layout.manifest.test.ts +205 -0
- package/src/layout.test.tsx +198 -1
- package/src/layout.tsx +208 -11
- package/src/layoutContract.test.tsx +311 -2
- package/src/layoutContract.ts +44 -3
- package/src/layoutDeclaration.test.ts +435 -0
- package/src/layoutDeclaration.ts +531 -0
- package/src/locale.tsx +3 -0
- package/src/markers.test.ts +141 -15
- package/src/nav.test.ts +234 -4
- package/src/nav.ts +180 -6
- package/src/navActive.test.ts +115 -0
- package/src/navActive.ts +119 -0
- package/src/navLink.tsx +20 -2
- package/src/previewBridge.test.ts +327 -0
- package/src/previewBridge.ts +278 -0
- package/src/raw.d.ts +14 -2
- package/src/review.test.tsx +272 -0
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +766 -3
- package/src/router.tsx +277 -28
- package/src/sso.test.tsx +6 -3
- package/src/styles.test.ts +518 -27
- package/src/styles.ts +1287 -66
- package/src/theme.test.tsx +29 -0
- package/src/theme.themes.test.ts +13 -7
- package/src/theme.tsx +30 -33
- package/src/themes.ts +54 -0
- package/src/toast.tsx +2 -1
- package/src/tokens.guard.test.ts +192 -0
- package/src/typography.test.tsx +213 -0
- package/src/typography.tsx +255 -0
- package/src/ui/Avatar.test.tsx +63 -0
- package/src/ui/Avatar.tsx +65 -0
- package/src/ui/Button.test.tsx +71 -3
- package/src/ui/Button.tsx +57 -4
- package/src/ui/Card.test.tsx +13 -0
- package/src/ui/Card.tsx +28 -1
- package/src/ui/Checkbox.tsx +10 -2
- package/src/ui/Combobox.test.tsx +49 -0
- package/src/ui/Combobox.tsx +8 -2
- package/src/ui/DatePicker.tsx +28 -5
- package/src/ui/Input.test.tsx +123 -0
- package/src/ui/Input.tsx +65 -2
- package/src/ui/Menu.tsx +16 -5
- package/src/ui/Popover.tsx +13 -0
- package/src/ui/Radio.tsx +10 -5
- package/src/ui/Select.test.tsx +232 -0
- package/src/ui/Select.tsx +177 -8
- package/src/ui/Switch.tsx +10 -2
- package/src/ui/Tabs.tsx +16 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.tsx +9 -0
- package/src/unwrap.test.ts +132 -0
- package/src/unwrap.ts +118 -32
package/src/Authorized.tsx
CHANGED
|
@@ -8,14 +8,47 @@ export function useCan(action: Action): boolean {
|
|
|
8
8
|
return useAuth().can(action);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The caller's effective permission names, from `GET /me` (ADR 0096).
|
|
13
|
+
*
|
|
14
|
+
* `useCan` compares role *rank*, which is all the wire used to carry. A screen whose write
|
|
15
|
+
* needs a **named** grant (`definitions.publish`) therefore had nothing to ask: it hid by
|
|
16
|
+
* rank as a proxy and handled the 403 anyway — showing a button it knew might fail, or
|
|
17
|
+
* hiding one the user was entitled to. This is the same set the server's guard enforces,
|
|
18
|
+
* projected for display.
|
|
19
|
+
*
|
|
20
|
+
* Empty when signed out, and empty for an app that mounts no grant capability (it has no
|
|
21
|
+
* named permissions). A *display* input only: the server re-checks every request, and a
|
|
22
|
+
* client that treats this as authority has moved the gate to the wrong side of the wire.
|
|
23
|
+
*/
|
|
24
|
+
export function usePermissions(): readonly string[] {
|
|
25
|
+
return useAuth().currentUser()?.permissions ?? [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether the current user holds the named permission grant (the UI gate). */
|
|
29
|
+
export function useHasPermission(permission: string): boolean {
|
|
30
|
+
return usePermissions().includes(permission);
|
|
31
|
+
}
|
|
32
|
+
|
|
11
33
|
export interface AuthorizedProps {
|
|
12
34
|
action: Action;
|
|
13
35
|
children: ReactNode;
|
|
36
|
+
/**
|
|
37
|
+
* Also require this named permission grant.
|
|
38
|
+
*
|
|
39
|
+
* Both must pass, which is deliberately what the server does: a `Policy` carrying a
|
|
40
|
+
* `Permission` enforces the permission's role floor **and** the grant, so a UI that
|
|
41
|
+
* checked only one would disagree with the endpoint in one direction or the other.
|
|
42
|
+
*/
|
|
43
|
+
permission?: string;
|
|
14
44
|
/** Rendered when the user may not perform `action` (default: nothing). */
|
|
15
45
|
fallback?: ReactNode;
|
|
16
46
|
}
|
|
17
47
|
|
|
18
48
|
/** Render `children` only when the current user may perform `action`, else `fallback`. */
|
|
19
|
-
export function Authorized({ action, children, fallback = null }: AuthorizedProps) {
|
|
20
|
-
|
|
49
|
+
export function Authorized({ action, permission, children, fallback = null }: AuthorizedProps) {
|
|
50
|
+
const allowedByRank = useCan(action);
|
|
51
|
+
const permissions = usePermissions();
|
|
52
|
+
const allowed = allowedByRank && (permission === undefined || permissions.includes(permission));
|
|
53
|
+
return <>{allowed ? children : fallback}</>;
|
|
21
54
|
}
|
package/src/Field.test.tsx
CHANGED
|
@@ -22,6 +22,36 @@ describe("Field", () => {
|
|
|
22
22
|
expect(screen.getByText("required")).toBeInTheDocument();
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
it("exposes the error as an alert, and nothing else in the field", () => {
|
|
26
|
+
// `aria-describedby` is read when focus reaches the control. That covers an error which was
|
|
27
|
+
// already there and covers nothing about one that arrives on submit, when focus has left the
|
|
28
|
+
// field and the only thing that changed is a span nobody is pointed at. The two channels fire
|
|
29
|
+
// at different moments, and a submit-time rejection only has the second one.
|
|
30
|
+
//
|
|
31
|
+
// The length assertion is the half with teeth: `role="alert"` on the hint as well would
|
|
32
|
+
// satisfy a bare `getByRole` while training the user to ignore the channel the error needs.
|
|
33
|
+
// Mutation: drop `role="alert"` from the span and the lookup finds nothing.
|
|
34
|
+
render(
|
|
35
|
+
<Field label="Email" hint="we never share it" error="required">
|
|
36
|
+
<Input defaultValue="" />
|
|
37
|
+
</Field>,
|
|
38
|
+
);
|
|
39
|
+
expect(screen.getAllByRole("alert")).toHaveLength(1);
|
|
40
|
+
expect(screen.getByRole("alert")).toHaveTextContent("required");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("raises no alert when there is nothing wrong", () => {
|
|
44
|
+
// An alert that is present on every render is an alert that means nothing. The span is
|
|
45
|
+
// conditional, so it enters the accessibility tree exactly when the error appears, which is
|
|
46
|
+
// the event the role exists to report.
|
|
47
|
+
render(
|
|
48
|
+
<Field label="Email" hint="we never share it">
|
|
49
|
+
<Input defaultValue="" />
|
|
50
|
+
</Field>,
|
|
51
|
+
);
|
|
52
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
25
55
|
it("renders no error node when error is null", () => {
|
|
26
56
|
render(
|
|
27
57
|
<Field label="Name" error={null}>
|
package/src/Field.tsx
CHANGED
|
@@ -31,6 +31,14 @@ export interface FieldProps {
|
|
|
31
31
|
* declares either keeps its own value; the field adds to `aria-describedby` rather than replacing
|
|
32
32
|
* it. `Input`, `Select`, `Textarea` and `Combobox` all spread their props onto the DOM element, so
|
|
33
33
|
* the attributes land where assistive tech reads them.
|
|
34
|
+
*
|
|
35
|
+
* The error also carries `role="alert"`, and `aria-describedby` is why it has to. A description is
|
|
36
|
+
* read when focus reaches the control, which covers an error that was already there and covers
|
|
37
|
+
* nothing about one that appears on submit — by then focus has left the field, or the button, and
|
|
38
|
+
* the only thing that changed is a span nobody is pointed at. The two are not redundant: they
|
|
39
|
+
* announce at different moments, and a submit-time rejection only has the second one. Because the
|
|
40
|
+
* span is conditional, it enters the accessibility tree exactly when the error appears, which is
|
|
41
|
+
* the event `alert` exists to report.
|
|
34
42
|
*/
|
|
35
43
|
export function Field({ label, children, error, hint }: FieldProps) {
|
|
36
44
|
const resolve = useUiText();
|
|
@@ -39,17 +47,35 @@ export function Field({ label, children, error, hint }: FieldProps) {
|
|
|
39
47
|
const hintId = hint !== undefined ? `${baseId}-hint` : undefined;
|
|
40
48
|
const errorId = hasError ? `${baseId}-error` : undefined;
|
|
41
49
|
const described = [hintId, errorId].filter((id) => id !== undefined).join(" ");
|
|
50
|
+
const labelId = `${baseId}-label`;
|
|
42
51
|
|
|
43
|
-
// Only a single element child can be described — which is the documented contract
|
|
44
|
-
// control"). Anything else is passed through untouched rather than guessed at.
|
|
45
|
-
const control =
|
|
52
|
+
// Only a single element child can be named and described — which is the documented contract
|
|
53
|
+
// ("the control"). Anything else is passed through untouched rather than guessed at.
|
|
54
|
+
const control = isValidElement<{
|
|
46
55
|
"aria-describedby"?: string;
|
|
47
56
|
"aria-invalid"?: boolean | "true" | "false";
|
|
57
|
+
"aria-label"?: string;
|
|
58
|
+
"aria-labelledby"?: string;
|
|
48
59
|
}>(children)
|
|
49
60
|
? cloneElement(children, {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
// The control is named by the label TEXT, not by the label element's subtree, and that
|
|
62
|
+
// distinction is the whole reason this exists. A wrapping label takes its name from
|
|
63
|
+
// everything inside it, so a control that renders an adornment of its own — the password
|
|
64
|
+
// reveal is the first — hands its own button's name to the field: Chromium computes
|
|
65
|
+
// "Password Show password" for that input, which is a WCAG 2.5.3 failure and a sentence
|
|
66
|
+
// no voice-control user can see to say. Pointing at the span makes the name exact.
|
|
67
|
+
//
|
|
68
|
+
// A caller that named the control itself keeps their name; this never overrides one.
|
|
69
|
+
"aria-labelledby":
|
|
70
|
+
children.props["aria-label"] === undefined
|
|
71
|
+
? (children.props["aria-labelledby"] ?? labelId)
|
|
72
|
+
: undefined,
|
|
73
|
+
"aria-describedby":
|
|
74
|
+
described.length > 0
|
|
75
|
+
? [children.props["aria-describedby"], described]
|
|
76
|
+
.filter((id) => id !== undefined && id !== "")
|
|
77
|
+
.join(" ")
|
|
78
|
+
: children.props["aria-describedby"],
|
|
53
79
|
"aria-invalid": children.props["aria-invalid"] ?? (hasError ? true : undefined),
|
|
54
80
|
})
|
|
55
81
|
: children;
|
|
@@ -57,7 +83,9 @@ export function Field({ label, children, error, hint }: FieldProps) {
|
|
|
57
83
|
return (
|
|
58
84
|
<div data-terp="field">
|
|
59
85
|
<label data-terp="field-label">
|
|
60
|
-
<span data-terp="field-label-text">
|
|
86
|
+
<span id={labelId} data-terp="field-label-text">
|
|
87
|
+
{resolve(label)}
|
|
88
|
+
</span>
|
|
61
89
|
{control}
|
|
62
90
|
</label>
|
|
63
91
|
{hint !== undefined && (
|
|
@@ -66,7 +94,7 @@ export function Field({ label, children, error, hint }: FieldProps) {
|
|
|
66
94
|
</span>
|
|
67
95
|
)}
|
|
68
96
|
{hasError && (
|
|
69
|
-
<span id={errorId} data-terp="field-error">
|
|
97
|
+
<span id={errorId} role="alert" data-terp="field-error">
|
|
70
98
|
{error}
|
|
71
99
|
</span>
|
|
72
100
|
)}
|
package/src/FormPage.tsx
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { BreadcrumbItem } from "./Breadcrumbs";
|
|
2
|
+
import { LayoutSlotContext } from "./layoutContract";
|
|
3
|
+
import { Page } from "./Page";
|
|
4
|
+
import type { PageProps } from "./Page";
|
|
5
|
+
|
|
6
|
+
export interface FormPageProps extends Omit<PageProps, "breadcrumbs"> {
|
|
7
|
+
/**
|
|
8
|
+
* The layers this form sits under, outermost first — the list it was reached from, and
|
|
9
|
+
* anything above that.
|
|
10
|
+
*
|
|
11
|
+
* Required, and for the reason `DetailPage`'s is: a create or edit screen is always reached
|
|
12
|
+
* from somewhere, and a form with no way back is a dead end with unsaved work in it.
|
|
13
|
+
*/
|
|
14
|
+
parents: readonly (BreadcrumbItem & { to: string })[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The form archetype: one create-or-edit screen, reached from the collection it writes into.
|
|
19
|
+
*
|
|
20
|
+
* It is a `Page` with two things fixed. The frame is `measure="narrow"` by default — header
|
|
21
|
+
* included — because a form is a single column of controls and a Save button a screen-width
|
|
22
|
+
* from its last field is worse than one sitting over it. And the body slot admits the shape a
|
|
23
|
+
* form actually has rather than the shape a record screen has.
|
|
24
|
+
*
|
|
25
|
+
* **The body is a container, so `Field` is deliberately not admitted directly.** The slot takes
|
|
26
|
+
* the form container, plus `Grid` for a two-column arrangement inside a wide form, `Card` for a
|
|
27
|
+
* grouped section, `Divider` and `Text` between them, and the framework states.
|
|
28
|
+
*
|
|
29
|
+
* What that refusal does and does not buy is worth stating exactly, because the obvious claim is
|
|
30
|
+
* too strong. It keeps fields out of the top level, so a form body is always a container — which
|
|
31
|
+
* is the shape the archetype is for. It does **not** guarantee the form can be submitted: both
|
|
32
|
+
* halves of the contract match on `data-terp` markers, and `Stack` renders the same marker
|
|
33
|
+
* whether or not it was given `as="form"`. So `<Stack><Field/></Stack>` passes and is still
|
|
34
|
+
* unsubmittable by Enter. Closing that would mean a second marker for the form case, which is
|
|
35
|
+
* six more names describing the same DOM — the `Section` trade 4b already declined.
|
|
36
|
+
*
|
|
37
|
+
* It renders no element of its own — the slot context and `Page`, nothing between them, because
|
|
38
|
+
* a wrapper around the body would become the sole entry in `article.children` and fail every
|
|
39
|
+
* governed page closed (ADR 0079).
|
|
40
|
+
*/
|
|
41
|
+
export function FormPage({ parents, measure = "narrow", ...page }: FormPageProps) {
|
|
42
|
+
return (
|
|
43
|
+
<LayoutSlotContext.Provider value="FormPage">
|
|
44
|
+
{/* Spread FIRST, then the archetype's own props — `HubPage`'s order, not
|
|
45
|
+
`DetailPage`'s. `Omit<PageProps, "breadcrumbs">` removes the key from the type but not
|
|
46
|
+
from a runtime object, and a JSX spread gets no excess-property check: a wrapper
|
|
47
|
+
forwarding `{...props}` with a present-but-undefined `breadcrumbs` would otherwise
|
|
48
|
+
overwrite `parents` and silently drop the trail this archetype requires.
|
|
49
|
+
(`DetailPage` and `OverviewPage` still spread last. Same latent shape, pre-existing,
|
|
50
|
+
and left for a change that can re-record their baselines rather than this one.) */}
|
|
51
|
+
<Page {...page} breadcrumbs={parents} measure={measure} />
|
|
52
|
+
</LayoutSlotContext.Provider>
|
|
53
|
+
);
|
|
54
|
+
}
|
package/src/LoginView.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { FormEvent } from "react";
|
|
3
3
|
|
|
4
4
|
import { TerpMark } from "./icons";
|
|
5
5
|
import { useAuth, useSso } from "./TerpProvider";
|
|
@@ -8,67 +8,6 @@ import { Button } from "./ui/Button";
|
|
|
8
8
|
import { Input } from "./ui/Input";
|
|
9
9
|
import type { SsoProvider } from "./sso";
|
|
10
10
|
|
|
11
|
-
const pageStyle: CSSProperties = {
|
|
12
|
-
minHeight: "100vh",
|
|
13
|
-
display: "grid",
|
|
14
|
-
placeItems: "center",
|
|
15
|
-
padding: "var(--space-6)",
|
|
16
|
-
background: "var(--color-neutral-50)",
|
|
17
|
-
fontFamily: "var(--font-family-sans)",
|
|
18
|
-
color: "var(--color-neutral-900)",
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const cardStyle: CSSProperties = {
|
|
22
|
-
width: "100%",
|
|
23
|
-
maxWidth: "24rem",
|
|
24
|
-
display: "grid",
|
|
25
|
-
gap: "var(--space-4)",
|
|
26
|
-
padding: "var(--space-6)",
|
|
27
|
-
background: "var(--color-neutral-0)",
|
|
28
|
-
border: "1px solid var(--color-neutral-200)",
|
|
29
|
-
borderRadius: "var(--radius-lg)",
|
|
30
|
-
boxShadow: "var(--shadow-md)",
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const brandStyle: CSSProperties = {
|
|
34
|
-
display: "flex",
|
|
35
|
-
alignItems: "center",
|
|
36
|
-
gap: "var(--space-2)",
|
|
37
|
-
color: "var(--color-neutral-900)",
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const brandTitleStyle: CSSProperties = {
|
|
41
|
-
margin: 0,
|
|
42
|
-
fontSize: "var(--font-size-xl)",
|
|
43
|
-
fontWeight: "var(--font-weight-bold)" as CSSProperties["fontWeight"],
|
|
44
|
-
letterSpacing: 0,
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const formStyle: CSSProperties = { display: "grid", gap: "var(--space-3)" };
|
|
48
|
-
|
|
49
|
-
const fullWidthStyle: CSSProperties = { width: "100%" };
|
|
50
|
-
|
|
51
|
-
const separatorStyle: CSSProperties = {
|
|
52
|
-
display: "flex",
|
|
53
|
-
alignItems: "center",
|
|
54
|
-
gap: "var(--space-2)",
|
|
55
|
-
color: "var(--color-neutral-500)",
|
|
56
|
-
fontSize: "var(--font-size-xs)",
|
|
57
|
-
textTransform: "uppercase",
|
|
58
|
-
letterSpacing: "0.06em",
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const separatorRuleStyle: CSSProperties = {
|
|
62
|
-
flex: 1,
|
|
63
|
-
borderTop: "1px solid var(--color-neutral-200)",
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const errorStyle: CSSProperties = {
|
|
67
|
-
margin: 0,
|
|
68
|
-
color: "var(--color-status-danger)",
|
|
69
|
-
fontSize: "var(--font-size-sm)",
|
|
70
|
-
};
|
|
71
|
-
|
|
72
11
|
export interface LoginViewProps {
|
|
73
12
|
/**
|
|
74
13
|
* SSO providers to offer alongside the credentials form (ADR 0058). Each renders a
|
|
@@ -95,6 +34,19 @@ export interface DevCredentials {
|
|
|
95
34
|
* The default signed-out screen: collects credentials and calls the auth session. Used by
|
|
96
35
|
* {@link renderTerpApp}/`RequireAuth` unless an app supplies its own login view. Pass
|
|
97
36
|
* {@link LoginViewProps.ssoProviders} to add SSO provider buttons under the form.
|
|
37
|
+
*
|
|
38
|
+
* It renders no inline styles: the full-viewport page, the card, the brand row, both button
|
|
39
|
+
* groups, the separator and the error line take their geometry and ink from the injected
|
|
40
|
+
* react-core sheet (ADR 0094).
|
|
41
|
+
*
|
|
42
|
+
* Two things here used to be worked around and are not any more, which is worth recording
|
|
43
|
+
* because the workarounds were both reasonable while they lasted. The buttons filled their
|
|
44
|
+
* group through a rule on the GROUP, because `Button` declares `width: fit-content` — a
|
|
45
|
+
* definite width, so a grid does not stretch them for free — and a prop for one internal
|
|
46
|
+
* caller was API the package did not need. `Button` has `fullWidth` now, so the rule retires
|
|
47
|
+
* and the four call sites say what they mean. And the submit button hand-rolled a busy state
|
|
48
|
+
* out of `disabled` plus a swapped label; `loading` is that state, and it adds the spinner and
|
|
49
|
+
* the `aria-busy` the hand-rolled version never had.
|
|
98
50
|
*/
|
|
99
51
|
export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps = {}) {
|
|
100
52
|
const auth = useAuth();
|
|
@@ -133,15 +85,18 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
133
85
|
const ssoError = sso.error !== null && sso.error !== undefined ? strings.ssoFailed : null;
|
|
134
86
|
|
|
135
87
|
return (
|
|
136
|
-
<main
|
|
137
|
-
<div
|
|
138
|
-
<div
|
|
88
|
+
<main data-terp="login-view">
|
|
89
|
+
<div data-terp="login-card">
|
|
90
|
+
<div data-terp="login-brand">
|
|
139
91
|
<TerpMark />
|
|
140
|
-
<h1
|
|
92
|
+
<h1 data-terp="login-title">{strings.signIn}</h1>
|
|
141
93
|
</div>
|
|
142
|
-
<form
|
|
94
|
+
<form data-terp="login-form" onSubmit={onSubmit}>
|
|
143
95
|
<Input
|
|
144
96
|
type="email"
|
|
97
|
+
// Neither field declared an autocomplete token, so no password manager offered to
|
|
98
|
+
// fill or save this form — the one place in the framework where that matters most.
|
|
99
|
+
autoComplete="username"
|
|
145
100
|
placeholder={strings.email}
|
|
146
101
|
value={email}
|
|
147
102
|
onChange={(event) => setEmail(event.target.value)}
|
|
@@ -149,20 +104,21 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
149
104
|
/>
|
|
150
105
|
<Input
|
|
151
106
|
type="password"
|
|
107
|
+
autoComplete="current-password"
|
|
152
108
|
placeholder={strings.password}
|
|
153
109
|
value={password}
|
|
154
110
|
onChange={(event) => setPassword(event.target.value)}
|
|
155
111
|
required
|
|
156
112
|
/>
|
|
157
|
-
<Button type="submit"
|
|
113
|
+
<Button type="submit" fullWidth loading={busy}>
|
|
158
114
|
{busy ? strings.signingIn : strings.signIn}
|
|
159
115
|
</Button>
|
|
160
116
|
{devCredentials ? (
|
|
161
117
|
<Button
|
|
162
118
|
type="button"
|
|
163
119
|
variant="secondary"
|
|
120
|
+
fullWidth
|
|
164
121
|
disabled={busy}
|
|
165
|
-
style={fullWidthStyle}
|
|
166
122
|
onClick={() => {
|
|
167
123
|
setEmail(devCredentials.email);
|
|
168
124
|
setPassword(devCredentials.password);
|
|
@@ -175,19 +131,19 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
175
131
|
</form>
|
|
176
132
|
{ssoProviders.length > 0 ? (
|
|
177
133
|
<>
|
|
178
|
-
<div
|
|
179
|
-
<span
|
|
134
|
+
<div data-terp="login-separator" aria-hidden="true">
|
|
135
|
+
<span data-terp="login-separator-rule" />
|
|
180
136
|
<span>{strings.orSeparator}</span>
|
|
181
|
-
<span
|
|
137
|
+
<span data-terp="login-separator-rule" />
|
|
182
138
|
</div>
|
|
183
|
-
<div
|
|
139
|
+
<div data-terp="login-sso">
|
|
184
140
|
{ssoProviders.map((provider) => (
|
|
185
141
|
<Button
|
|
186
142
|
key={provider.name}
|
|
187
143
|
type="button"
|
|
188
144
|
variant="secondary"
|
|
145
|
+
fullWidth
|
|
189
146
|
disabled={busy}
|
|
190
|
-
style={fullWidthStyle}
|
|
191
147
|
onClick={() => void onSso(provider)}
|
|
192
148
|
>
|
|
193
149
|
{`${strings.continueWith} ${provider.label ?? provider.name}`}
|
|
@@ -196,7 +152,11 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
196
152
|
</div>
|
|
197
153
|
</>
|
|
198
154
|
) : null}
|
|
199
|
-
{error ?? ssoError ?
|
|
155
|
+
{error ?? ssoError ? (
|
|
156
|
+
<p role="alert" data-terp="login-error">
|
|
157
|
+
{error ?? ssoError}
|
|
158
|
+
</p>
|
|
159
|
+
) : null}
|
|
200
160
|
</div>
|
|
201
161
|
</main>
|
|
202
162
|
);
|
package/src/ModuleNav.test.tsx
CHANGED
|
@@ -60,6 +60,32 @@ describe("ModuleNav", () => {
|
|
|
60
60
|
"page",
|
|
61
61
|
);
|
|
62
62
|
expect(screen.getByRole("link", { name: "Overview" })).not.toHaveAttribute("aria-current");
|
|
63
|
+
|
|
64
|
+
// The styling key is data-active, and the point is that it is NOT the aria-current beside
|
|
65
|
+
// it: a router Link merges its own active props last, so that attribute has a second author.
|
|
66
|
+
//
|
|
67
|
+
// The divergence this comment used to describe is fixed (ADR 0097 §6, amended in 4e). Both
|
|
68
|
+
// authors now compute the same thing by construction rather than by coincidence. The
|
|
69
|
+
// component asks `activeNavPath` over the whole strip; the Link is pinned to
|
|
70
|
+
// `exact: true, includeSearch: false`, so the router volunteers `aria-current` only when the
|
|
71
|
+
// path equals the URL — the longest possible match, hence always the tab the predicate
|
|
72
|
+
// picked. It can agree or stay silent; it cannot name a different tab. Which is why the
|
|
73
|
+
// assertion below can now demand exactly one `aria-current` in the strip.
|
|
74
|
+
const active = screen.getByRole("link", { name: "Projects" });
|
|
75
|
+
const inactive = screen.getByRole("link", { name: "Overview" });
|
|
76
|
+
expect(active).toHaveAttribute("data-terp", "module-nav-link");
|
|
77
|
+
expect(active).toHaveAttribute("data-active", "true");
|
|
78
|
+
expect(inactive).toHaveAttribute("data-terp", "module-nav-link");
|
|
79
|
+
expect(inactive).not.toHaveAttribute("data-active");
|
|
80
|
+
expect(active.getAttribute("style")).toBeNull();
|
|
81
|
+
expect(inactive.getAttribute("style")).toBeNull();
|
|
82
|
+
|
|
83
|
+
// Exactly one, over the whole strip. Before 4e the Link was left prefix-matching, so at
|
|
84
|
+
// /tickets/projects the router added its own aria-current to the /tickets tab as well and a
|
|
85
|
+
// screen reader announced two current pages.
|
|
86
|
+
expect(
|
|
87
|
+
document.querySelectorAll('[data-terp="module-nav"] [aria-current="page"]'),
|
|
88
|
+
).toHaveLength(1);
|
|
63
89
|
});
|
|
64
90
|
|
|
65
91
|
it("returns nothing for an empty tab list", () => {
|
package/src/ModuleNav.tsx
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import { Link, useRouterState } from "@tanstack/react-router";
|
|
2
|
-
import type { CSSProperties } from "react";
|
|
3
2
|
|
|
3
|
+
import { activeNavPath } from "./navActive";
|
|
4
4
|
import { useStrings, useUiText } from "./uiText";
|
|
5
5
|
import type { UiText } from "./uiText";
|
|
6
6
|
|
|
7
7
|
export interface ModuleNavTab {
|
|
8
8
|
/** Display label. */
|
|
9
9
|
label: UiText;
|
|
10
|
-
/** Router path for
|
|
10
|
+
/** Router path for the sub-page route. */
|
|
11
11
|
to: string;
|
|
12
|
+
/**
|
|
13
|
+
* Match this tab's path exactly rather than as a segment-aligned prefix.
|
|
14
|
+
*
|
|
15
|
+
* The default is the prefix, so a tab stays current on the pages beneath it — a detail route
|
|
16
|
+
* under `/records/mapping` keeps "Mapping" lit instead of blanking the strip. Set this on a
|
|
17
|
+
* landing tab that also has siblings deeper in the same strip, where the prefix would keep it
|
|
18
|
+
* lit alongside them; the shared predicate resolves that case by longest match anyway, so this
|
|
19
|
+
* is for the narrower job of a tab owning only itself.
|
|
20
|
+
*/
|
|
21
|
+
exact?: boolean;
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
export interface ModuleNavProps {
|
|
@@ -17,65 +27,62 @@ export interface ModuleNavProps {
|
|
|
17
27
|
ariaLabel?: UiText;
|
|
18
28
|
}
|
|
19
29
|
|
|
20
|
-
const navStyle: CSSProperties = {
|
|
21
|
-
borderBottom: "1px solid var(--color-neutral-200)",
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
const listStyle: CSSProperties = {
|
|
25
|
-
listStyle: "none",
|
|
26
|
-
margin: 0,
|
|
27
|
-
padding: 0,
|
|
28
|
-
display: "flex",
|
|
29
|
-
flexWrap: "wrap",
|
|
30
|
-
gap: "var(--space-3)",
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const linkStyle: CSSProperties = {
|
|
34
|
-
display: "inline-flex",
|
|
35
|
-
alignItems: "center",
|
|
36
|
-
padding: "var(--space-2) 0",
|
|
37
|
-
color: "var(--color-neutral-600)",
|
|
38
|
-
textDecoration: "none",
|
|
39
|
-
borderBottom: "2px solid transparent",
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const activeLinkStyle: CSSProperties = {
|
|
43
|
-
color: "var(--color-neutral-900)",
|
|
44
|
-
borderBottomColor: "var(--color-fg-accent)",
|
|
45
|
-
};
|
|
46
|
-
|
|
47
30
|
/**
|
|
48
31
|
* Secondary horizontal navigation for intra-module sub-pages.
|
|
49
32
|
*
|
|
50
33
|
* Each tab links to a real TanStack Router route so sub-pages keep their own URL,
|
|
51
34
|
* lazy loading, and back-button behavior. The active tab is matched exactly.
|
|
35
|
+
*
|
|
36
|
+
* It renders no inline styles: the strip, the list and the links take their geometry and
|
|
37
|
+
* ink from the injected react-core sheet (ADR 0094). The active tab is styled from
|
|
38
|
+
* `data-active`, which this component alone writes, rather than from the `aria-current` it
|
|
39
|
+
* also sets — a router `Link` merges its own `aria-current` last, so that attribute has a
|
|
40
|
+
* second author and would be the breadcrumb mistake again.
|
|
52
41
|
*/
|
|
53
42
|
export function ModuleNav({ items, ariaLabel }: ModuleNavProps) {
|
|
54
43
|
const strings = useStrings();
|
|
55
44
|
const resolve = useUiText();
|
|
56
45
|
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
46
|
+
// Resolved over the whole strip, not per tab, and through the same function the sidebar uses
|
|
47
|
+
// (ADR 0097 §6, amended in 4e). Two tabs where one path prefixes the other would otherwise
|
|
48
|
+
// both be current, and this component previously compared `pathname === item.to` raw — which
|
|
49
|
+
// diverged from its own Link in both directions, as the sheet's comment on the active rule
|
|
50
|
+
// says. Longest match wins, so `/records` does not steal from `/records/mapping`.
|
|
51
|
+
const activeTo = activeNavPath(pathname, items);
|
|
57
52
|
|
|
58
53
|
if (items.length === 0) {
|
|
59
54
|
return null;
|
|
60
55
|
}
|
|
61
56
|
|
|
62
57
|
return (
|
|
63
|
-
<nav
|
|
64
|
-
|
|
65
|
-
data-terp="module-nav"
|
|
66
|
-
style={navStyle}
|
|
67
|
-
>
|
|
68
|
-
<ul style={listStyle}>
|
|
58
|
+
<nav aria-label={resolve(ariaLabel ?? strings.moduleNavigationLabel)} data-terp="module-nav">
|
|
59
|
+
<ul data-terp="module-nav-list">
|
|
69
60
|
{items.map((item) => {
|
|
70
61
|
const label = resolve(item.label);
|
|
71
|
-
const isActive =
|
|
62
|
+
const isActive = item.to === activeTo;
|
|
72
63
|
return (
|
|
73
64
|
<li key={item.to}>
|
|
74
65
|
<Link
|
|
75
66
|
to={item.to}
|
|
76
|
-
|
|
67
|
+
// `exact` is ALWAYS true here, whatever the tab asked for, and the asymmetry is
|
|
68
|
+
// the mechanism rather than an oversight. The tab's own `exact` governs the
|
|
69
|
+
// component's predicate above; this governs when the ROUTER volunteers its own
|
|
70
|
+
// `aria-current`, and the router decides per link with no knowledge of siblings.
|
|
71
|
+
// Left non-exact it prefix-matches, so at `/tickets/projects` it would mark the
|
|
72
|
+
// `/tickets` tab current too — a second current item the component never chose.
|
|
73
|
+
// Exact matching means "the router thinks this is active" implies the path equals
|
|
74
|
+
// the URL, which is the longest possible match, which is always the tab the
|
|
75
|
+
// predicate picked. The router can only agree.
|
|
76
|
+
//
|
|
77
|
+
// includeSearch: false for the other half. It defaults to true and on an exact
|
|
78
|
+
// link demands a full query-string match, so at `/tickets/projects?page=2` the
|
|
79
|
+
// router's own `data-status` said inactive while this component said active.
|
|
80
|
+
// Nothing paints from `data-status`, which is exactly why that could sit there
|
|
81
|
+
// unnoticed.
|
|
82
|
+
activeOptions={{ exact: true, includeSearch: false }}
|
|
77
83
|
aria-current={isActive ? "page" : undefined}
|
|
78
|
-
|
|
84
|
+
data-terp="module-nav-link"
|
|
85
|
+
data-active={isActive ? "true" : undefined}
|
|
79
86
|
>
|
|
80
87
|
{label}
|
|
81
88
|
</Link>
|
package/src/Page.test.tsx
CHANGED
|
@@ -18,13 +18,16 @@ describe("Page", () => {
|
|
|
18
18
|
</Page>,
|
|
19
19
|
);
|
|
20
20
|
|
|
21
|
-
expect(screen.getByRole("heading", { level: 1, name: "Tasks" })).toBeInTheDocument();
|
|
22
|
-
expect(screen.getByRole("heading", { level: 1, name: "Tasks" })).toHaveStyle({
|
|
23
|
-
fontSize: "var(--font-size-lg)",
|
|
24
|
-
letterSpacing: "0",
|
|
25
|
-
});
|
|
26
|
-
expect(screen.getByRole("button", { name: "New" })).toBeInTheDocument();
|
|
27
21
|
const title = screen.getByRole("heading", { level: 1, name: "Tasks" });
|
|
22
|
+
expect(title).toBeInTheDocument();
|
|
23
|
+
// The marker, not the declaration. jsdom computes no cascade, so toHaveStyle could only
|
|
24
|
+
// ever see an inline style — asserting the title's font size was asserting that Page
|
|
25
|
+
// styles itself from a style object, which is the thing ADR 0094 removes. What a unit
|
|
26
|
+
// test can hold is the fact the sheet keys on; the type is gated by styles.test.ts for
|
|
27
|
+
// existence and by the page-header baseline for what it renders as.
|
|
28
|
+
expect(title).toHaveAttribute("data-terp", "page-title");
|
|
29
|
+
expect(title.getAttribute("style")).toBeNull();
|
|
30
|
+
expect(screen.getByRole("button", { name: "New" })).toBeInTheDocument();
|
|
28
31
|
const action = screen.getByRole("button", { name: "New" });
|
|
29
32
|
expect(title.compareDocumentPosition(action) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
30
33
|
expect(screen.getByText("body")).toBeInTheDocument();
|