@terpjs/react-core 0.1.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 +190 -0
- package/package.json +44 -0
- package/src/AppShell.test.tsx +152 -0
- package/src/AppShell.tsx +554 -0
- package/src/Authorized.test.tsx +60 -0
- package/src/Authorized.tsx +21 -0
- package/src/Breadcrumbs.test.tsx +45 -0
- package/src/Breadcrumbs.tsx +110 -0
- package/src/ConfirmDialog.tsx +170 -0
- package/src/DetailPage.tsx +28 -0
- package/src/EmptyState.tsx +74 -0
- package/src/ErrorState.tsx +108 -0
- package/src/Field.test.tsx +53 -0
- package/src/Field.tsx +51 -0
- package/src/HubPage.test.tsx +108 -0
- package/src/HubPage.tsx +204 -0
- package/src/LoadingState.test.tsx +40 -0
- package/src/LoadingState.tsx +96 -0
- package/src/LoginView.test.tsx +57 -0
- package/src/LoginView.tsx +203 -0
- package/src/ModuleNav.test.tsx +96 -0
- package/src/ModuleNav.tsx +88 -0
- package/src/OverviewPage.tsx +26 -0
- package/src/Page.test.tsx +147 -0
- package/src/Page.tsx +158 -0
- package/src/PageActions.test.tsx +104 -0
- package/src/PageActions.tsx +72 -0
- package/src/ProfileView.test.tsx +112 -0
- package/src/ProfileView.tsx +84 -0
- package/src/RequireAuth.test.tsx +89 -0
- package/src/RequireAuth.tsx +22 -0
- package/src/ResourceList.test.tsx +176 -0
- package/src/ResourceList.tsx +123 -0
- package/src/TerpProvider.tsx +320 -0
- package/src/UserMenu.test.tsx +166 -0
- package/src/UserMenu.tsx +125 -0
- package/src/admin/AdminHub.tsx +108 -0
- package/src/admin/AuditLogAdmin.tsx +116 -0
- package/src/admin/GroupCreate.tsx +90 -0
- package/src/admin/GroupDetail.tsx +446 -0
- package/src/admin/GroupsAdmin.tsx +109 -0
- package/src/admin/UserCreate.tsx +115 -0
- package/src/admin/UserDetail.tsx +228 -0
- package/src/admin/UsersAdmin.tsx +111 -0
- package/src/admin/admin.test.tsx +537 -0
- package/src/admin/crumbs.tsx +14 -0
- package/src/admin/module.tsx +51 -0
- package/src/admin/roles.ts +19 -0
- package/src/bootstrap.test.tsx +67 -0
- package/src/bootstrap.tsx +270 -0
- package/src/capabilities.test.ts +24 -0
- package/src/capabilities.ts +26 -0
- package/src/createAuthClient.test.ts +176 -0
- package/src/createAuthClient.ts +105 -0
- package/src/dataview/DataView.test.tsx +392 -0
- package/src/dataview/DataView.tsx +467 -0
- package/src/dataview/DataViewCardList.tsx +189 -0
- package/src/dataview/DataViewColumnSettings.tsx +118 -0
- package/src/dataview/DataViewExpandableRow.tsx +67 -0
- package/src/dataview/DataViewPagination.tsx +113 -0
- package/src/dataview/DataViewRowActions.tsx +131 -0
- package/src/dataview/DataViewTable.tsx +359 -0
- package/src/dataview/DataViewToolbar.tsx +260 -0
- package/src/dataview/README.md +138 -0
- package/src/dataview/glyphs.tsx +175 -0
- package/src/dataview/hooks/hooks.test.tsx +240 -0
- package/src/dataview/hooks/useDataViewQuery.ts +72 -0
- package/src/dataview/hooks/useDataViewState.ts +310 -0
- package/src/dataview/hooks/useServerDataView.ts +154 -0
- package/src/dataview/hooks/useViewSearch.ts +68 -0
- package/src/dataview/index.ts +62 -0
- package/src/dataview/internal.tsx +96 -0
- package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
- package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
- package/src/dataview/repositories/repositories.test.ts +158 -0
- package/src/dataview/repositories/viewState.test.ts +90 -0
- package/src/dataview/repositories/viewState.ts +128 -0
- package/src/dataview/types.ts +249 -0
- package/src/errorMessages.test.tsx +83 -0
- package/src/errorMessages.tsx +79 -0
- package/src/feedback.test.tsx +167 -0
- package/src/files.test.tsx +142 -0
- package/src/files.tsx +174 -0
- package/src/icons.test.tsx +46 -0
- package/src/icons.tsx +533 -0
- package/src/index.ts +155 -0
- package/src/layout.test.tsx +72 -0
- package/src/layout.tsx +90 -0
- package/src/layoutContract.test.tsx +179 -0
- package/src/layoutContract.ts +137 -0
- package/src/locale.test.tsx +97 -0
- package/src/locale.tsx +246 -0
- package/src/nav.test.ts +21 -0
- package/src/nav.ts +13 -0
- package/src/pageMarker.ts +15 -0
- package/src/raw.d.ts +7 -0
- package/src/realtime-hook.test.tsx +226 -0
- package/src/realtime.test.ts +44 -0
- package/src/realtime.ts +307 -0
- package/src/refresh-session.test.tsx +114 -0
- package/src/revocation.test.tsx +81 -0
- package/src/router.test.tsx +307 -0
- package/src/router.tsx +222 -0
- package/src/sso.test.tsx +128 -0
- package/src/sso.ts +142 -0
- package/src/ssr.test.tsx +45 -0
- package/src/styles.test.ts +21 -0
- package/src/styles.ts +302 -0
- package/src/theme.test.tsx +74 -0
- package/src/theme.tsx +143 -0
- package/src/toast.test.tsx +94 -0
- package/src/toast.tsx +214 -0
- package/src/tokens.guard.test.ts +51 -0
- package/src/ui/Alert.test.tsx +19 -0
- package/src/ui/Alert.tsx +115 -0
- package/src/ui/Badge.test.tsx +14 -0
- package/src/ui/Badge.tsx +48 -0
- package/src/ui/Button.test.tsx +36 -0
- package/src/ui/Button.tsx +95 -0
- package/src/ui/Card.test.tsx +40 -0
- package/src/ui/Card.tsx +92 -0
- package/src/ui/Checkbox.test.tsx +17 -0
- package/src/ui/Checkbox.tsx +51 -0
- package/src/ui/Combobox.test.tsx +58 -0
- package/src/ui/Combobox.tsx +313 -0
- package/src/ui/DatePicker.test.tsx +60 -0
- package/src/ui/DatePicker.tsx +421 -0
- package/src/ui/Input.tsx +30 -0
- package/src/ui/Markdown.test.tsx +32 -0
- package/src/ui/Markdown.tsx +213 -0
- package/src/ui/Menu.test.tsx +85 -0
- package/src/ui/Menu.tsx +216 -0
- package/src/ui/Popover.tsx +218 -0
- package/src/ui/Radio.test.tsx +29 -0
- package/src/ui/Radio.tsx +127 -0
- package/src/ui/Select.tsx +40 -0
- package/src/ui/Switch.test.tsx +17 -0
- package/src/ui/Switch.tsx +53 -0
- package/src/ui/Tabs.test.tsx +29 -0
- package/src/ui/Tabs.tsx +128 -0
- package/src/ui/Textarea.tsx +27 -0
- package/src/ui/Tooltip.test.tsx +28 -0
- package/src/ui/Tooltip.tsx +67 -0
- package/src/ui/controlStyles.ts +9 -0
- package/src/uiText.test.tsx +93 -0
- package/src/uiText.tsx +342 -0
- package/src/unwrap.test.ts +67 -0
- package/src/unwrap.ts +101 -0
- package/src/useResource.test.tsx +118 -0
- package/src/useResource.ts +110 -0
- package/src/useTerpClient.test.ts +35 -0
- package/tsconfig.json +17 -0
- package/vite.config.ts +14 -0
- package/vitest.setup.ts +58 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { HubCard, HubPage } from "./HubPage";
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
describe("HubPage", () => {
|
|
10
|
+
it("renders the h1 title and a grid of cards", () => {
|
|
11
|
+
render(
|
|
12
|
+
<HubPage title="Administration">
|
|
13
|
+
<HubCard to="/users" title="Users" description="Manage accounts" />
|
|
14
|
+
<HubCard to="/roles" title="Roles" />
|
|
15
|
+
</HubPage>,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
expect(screen.getByRole("heading", { level: 1, name: "Administration" })).toBeInTheDocument();
|
|
19
|
+
expect(screen.queryByRole("navigation", { name: "Breadcrumb" })).not.toBeInTheDocument();
|
|
20
|
+
expect(screen.getAllByText("Administration")).toHaveLength(1);
|
|
21
|
+
expect(screen.getByRole("list")).toHaveStyle({ gridAutoRows: "1fr" });
|
|
22
|
+
expect(screen.getByRole("link", { name: /Users/ })).toBeInTheDocument();
|
|
23
|
+
expect(screen.getByRole("link", { name: /Roles/ })).toBeInTheDocument();
|
|
24
|
+
expect(screen.getByText("Manage accounts")).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("uses the normal breadcrumb frame when the hub is nested", () => {
|
|
28
|
+
render(
|
|
29
|
+
<HubPage title="Administration" parents={[{ label: "Home", to: "/" }]}>
|
|
30
|
+
<HubCard to="/users" title="Users" />
|
|
31
|
+
</HubPage>,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(screen.getByRole("navigation", { name: "Breadcrumb" })).toHaveTextContent(
|
|
35
|
+
"HomeAdministration",
|
|
36
|
+
);
|
|
37
|
+
expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute("href", "/");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("keeps the base Page breadcrumbs prop as a compatibility alias", () => {
|
|
41
|
+
render(
|
|
42
|
+
<HubPage title="Administration" breadcrumbs={[{ label: "Home", to: "/" }]}>
|
|
43
|
+
<HubCard to="/users" title="Users" />
|
|
44
|
+
</HubPage>,
|
|
45
|
+
);
|
|
46
|
+
expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute("href", "/");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("HubCard", () => {
|
|
51
|
+
it("links the whole card to its destination by default", () => {
|
|
52
|
+
render(
|
|
53
|
+
<HubPage title="Hub">
|
|
54
|
+
<HubCard to="/users" title="Users" />
|
|
55
|
+
</HubPage>,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(screen.getByRole("link", { name: "Users" })).toHaveAttribute("href", "/users");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("renders through the supplied link renderer and shows the live stat", () => {
|
|
62
|
+
render(
|
|
63
|
+
<HubPage title="Hub">
|
|
64
|
+
<HubCard
|
|
65
|
+
to="/users"
|
|
66
|
+
title="Users"
|
|
67
|
+
stat={<span>142 active</span>}
|
|
68
|
+
renderLink={({ to, children }) => (
|
|
69
|
+
<a href={to} data-router="stack">
|
|
70
|
+
{children}
|
|
71
|
+
</a>
|
|
72
|
+
)}
|
|
73
|
+
/>
|
|
74
|
+
</HubPage>,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const link = screen.getByRole("link", { name: /Users/ });
|
|
78
|
+
expect(link).toHaveAttribute("data-router", "stack");
|
|
79
|
+
expect(screen.getByText("142 active")).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("reserves the same internal tracks when optional card content is absent", () => {
|
|
83
|
+
render(
|
|
84
|
+
<HubPage title="Hub">
|
|
85
|
+
<HubCard to="/short" title="Short" />
|
|
86
|
+
<HubCard to="/full" title="Full" description="Description" stat="12" />
|
|
87
|
+
</HubPage>,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const bodies = screen.getAllByText(/Short|Full/, { selector: "strong" }).map(
|
|
91
|
+
(title) => title.closest('[data-terp="hubcard-body"]'),
|
|
92
|
+
);
|
|
93
|
+
expect(bodies).toHaveLength(2);
|
|
94
|
+
for (const body of bodies) {
|
|
95
|
+
expect(body).toHaveStyle({
|
|
96
|
+
gridTemplateRows: "auto minmax(3rem, 1fr) auto",
|
|
97
|
+
minHeight: "10rem",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const shortBody = bodies[0]!;
|
|
101
|
+
expect(shortBody.querySelector('[data-terp="hubcard-description"]')).toHaveStyle({
|
|
102
|
+
visibility: "hidden",
|
|
103
|
+
});
|
|
104
|
+
expect(shortBody.querySelector('[data-terp="hubcard-stat"]')).toHaveStyle({
|
|
105
|
+
visibility: "hidden",
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
package/src/HubPage.tsx
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { Page } from "./Page";
|
|
5
|
+
import type { PageProps } from "./Page";
|
|
6
|
+
import { useLayoutContract, verifySlotChildren } from "./layoutContract";
|
|
7
|
+
import { injectTerpStyles } from "./styles";
|
|
8
|
+
import { useUiText } from "./uiText";
|
|
9
|
+
import type { UiText } from "./uiText";
|
|
10
|
+
|
|
11
|
+
injectTerpStyles();
|
|
12
|
+
|
|
13
|
+
export type HubPageProps = Omit<
|
|
14
|
+
PageProps,
|
|
15
|
+
"isLoading" | "loadingState" | "error" | "errorState"
|
|
16
|
+
> & {
|
|
17
|
+
/** Parent trail for nested hubs; aliases the base Page's `breadcrumbs` prop. */
|
|
18
|
+
parents?: PageProps["breadcrumbs"];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const gridStyle: CSSProperties = {
|
|
22
|
+
display: "grid",
|
|
23
|
+
gridTemplateColumns: "repeat(auto-fit, minmax(min(16rem, 100%), 1fr))",
|
|
24
|
+
gridAutoRows: "1fr",
|
|
25
|
+
gap: "var(--space-4)",
|
|
26
|
+
alignItems: "stretch",
|
|
27
|
+
listStyle: "none",
|
|
28
|
+
margin: 0,
|
|
29
|
+
padding: 0,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The landing / hub page archetype: a `Page` whose body is a responsive grid of
|
|
34
|
+
* {@link HubCard} links into the sub-areas of a domain. Use it as a module index that
|
|
35
|
+
* adds discovery value (each card can carry a live `stat`, making the hub a lightweight
|
|
36
|
+
* dashboard) — never as a mandatory speed-bump in front of a single frequently-used list.
|
|
37
|
+
*/
|
|
38
|
+
export function HubPage({ children, parents, breadcrumbs, ...page }: HubPageProps) {
|
|
39
|
+
// The runtime half of the slot-typed layout contract control (ADR 0079) for the hub
|
|
40
|
+
// grid: with a contract active, every rendered child of the grid must be a HubCard
|
|
41
|
+
// (its data-terp marker) — verified one macrotask after mount, refused fail closed.
|
|
42
|
+
const contract = useLayoutContract();
|
|
43
|
+
const gridRef = useRef<HTMLUListElement>(null);
|
|
44
|
+
const [slotViolation, setSlotViolation] = useState<string | null>(null);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (contract === null) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const timer = setTimeout(() => {
|
|
50
|
+
const grid = gridRef.current;
|
|
51
|
+
if (grid === null) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
setSlotViolation(verifySlotChildren(contract, "HubPage", [...grid.children]));
|
|
55
|
+
}, 0);
|
|
56
|
+
return () => clearTimeout(timer);
|
|
57
|
+
});
|
|
58
|
+
if (slotViolation !== null) {
|
|
59
|
+
throw new Error(slotViolation);
|
|
60
|
+
}
|
|
61
|
+
return (
|
|
62
|
+
<Page {...page} breadcrumbs={parents ?? breadcrumbs}>
|
|
63
|
+
<ul ref={gridRef} style={gridStyle}>
|
|
64
|
+
{children}
|
|
65
|
+
</ul>
|
|
66
|
+
</Page>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Wraps a card's content in the active stack's link (keeps the hub router-agnostic). */
|
|
71
|
+
export type RenderHubCardLink = (props: { to: string; children: ReactNode }) => ReactNode;
|
|
72
|
+
|
|
73
|
+
export interface HubCardProps {
|
|
74
|
+
/** Destination path of the sub-area the card opens. */
|
|
75
|
+
to: string;
|
|
76
|
+
/** Card title (the sub-area's name). */
|
|
77
|
+
title: UiText;
|
|
78
|
+
/** Short explanation of the area. */
|
|
79
|
+
description?: UiText;
|
|
80
|
+
/** Optional leading icon (any rendered node — react-core takes no icon dependency). */
|
|
81
|
+
icon?: ReactNode;
|
|
82
|
+
/**
|
|
83
|
+
* Optional live summary (e.g. "142 active - 3 inactive"). Turns the hub into a
|
|
84
|
+
* lightweight dashboard rather than a duplicate of the sidebar.
|
|
85
|
+
*/
|
|
86
|
+
stat?: ReactNode;
|
|
87
|
+
/** Link renderer (default: a plain `<a href>`); pass the stack's `Link`, like `AppShell`. */
|
|
88
|
+
renderLink?: RenderHubCardLink;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const cardStyle: CSSProperties = {
|
|
92
|
+
height: "100%",
|
|
93
|
+
minHeight: 0,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const cardBodyStyle: CSSProperties = {
|
|
97
|
+
display: "grid",
|
|
98
|
+
gridTemplateRows: "auto minmax(3rem, 1fr) auto",
|
|
99
|
+
gap: "var(--space-2)",
|
|
100
|
+
height: "100%",
|
|
101
|
+
minHeight: "10rem",
|
|
102
|
+
padding: "var(--space-4)",
|
|
103
|
+
border: "1px solid var(--color-neutral-200)",
|
|
104
|
+
borderRadius: "var(--radius-lg)",
|
|
105
|
+
background: "var(--color-neutral-0)",
|
|
106
|
+
color: "var(--color-neutral-900)",
|
|
107
|
+
boxSizing: "border-box",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const cardTitleRowStyle: CSSProperties = {
|
|
111
|
+
margin: 0,
|
|
112
|
+
display: "flex",
|
|
113
|
+
alignItems: "center",
|
|
114
|
+
gap: "var(--space-3)",
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const iconTileStyle: CSSProperties = {
|
|
118
|
+
display: "inline-flex",
|
|
119
|
+
alignItems: "center",
|
|
120
|
+
justifyContent: "center",
|
|
121
|
+
width: "2.25rem",
|
|
122
|
+
height: "2.25rem",
|
|
123
|
+
flexShrink: 0,
|
|
124
|
+
borderRadius: "var(--radius-md)",
|
|
125
|
+
background: "var(--color-brand-primary-soft)",
|
|
126
|
+
color: "var(--color-brand-primary)",
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const titleTextStyle: CSSProperties = {
|
|
130
|
+
color: "var(--color-neutral-900)",
|
|
131
|
+
fontSize: "var(--font-size-base)",
|
|
132
|
+
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
133
|
+
transition: "color 150ms ease",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const descriptionStyle: CSSProperties = {
|
|
137
|
+
margin: 0,
|
|
138
|
+
color: "var(--color-neutral-600)",
|
|
139
|
+
fontSize: "var(--font-size-sm)",
|
|
140
|
+
lineHeight: 1.5,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const emptyDescriptionStyle: CSSProperties = { ...descriptionStyle, visibility: "hidden" };
|
|
144
|
+
|
|
145
|
+
const statStyle: CSSProperties = {
|
|
146
|
+
color: "var(--color-neutral-900)",
|
|
147
|
+
fontSize: "var(--font-size-sm)",
|
|
148
|
+
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
149
|
+
};
|
|
150
|
+
const emptyStatStyle: CSSProperties = { ...statStyle, visibility: "hidden" };
|
|
151
|
+
|
|
152
|
+
const linkStyle: CSSProperties = {
|
|
153
|
+
textDecoration: "none",
|
|
154
|
+
color: "inherit",
|
|
155
|
+
display: "block",
|
|
156
|
+
height: "100%",
|
|
157
|
+
minHeight: 0,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const defaultRenderLink: RenderHubCardLink = ({ to, children }) => (
|
|
161
|
+
<a href={to} data-terp="hubcard-link" style={linkStyle}>
|
|
162
|
+
{children}
|
|
163
|
+
</a>
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A single navigable card inside a {@link HubPage}: icon + title, a short description of
|
|
168
|
+
* the area, and an optional live `stat`. The whole card is one link, rendered through
|
|
169
|
+
* `renderLink` so the hub stays router-agnostic.
|
|
170
|
+
*/
|
|
171
|
+
export function HubCard({
|
|
172
|
+
to,
|
|
173
|
+
title,
|
|
174
|
+
description,
|
|
175
|
+
icon,
|
|
176
|
+
stat,
|
|
177
|
+
renderLink = defaultRenderLink,
|
|
178
|
+
}: HubCardProps) {
|
|
179
|
+
const resolve = useUiText();
|
|
180
|
+
return (
|
|
181
|
+
<li data-terp="hubcard" style={cardStyle}>
|
|
182
|
+
{renderLink({
|
|
183
|
+
to,
|
|
184
|
+
children: (
|
|
185
|
+
<span data-terp="hubcard-body" style={cardBodyStyle}>
|
|
186
|
+
<span style={cardTitleRowStyle}>
|
|
187
|
+
{icon !== undefined && <span style={iconTileStyle}>{icon}</span>}
|
|
188
|
+
<strong data-terp="hubcard-title" style={titleTextStyle}>
|
|
189
|
+
{resolve(title)}
|
|
190
|
+
</strong>
|
|
191
|
+
</span>
|
|
192
|
+
<span data-terp="hubcard-description" style={description === undefined ? emptyDescriptionStyle : descriptionStyle}>
|
|
193
|
+
{description === undefined ? " " : resolve(description)}
|
|
194
|
+
</span>
|
|
195
|
+
<span data-terp="hubcard-stat" style={stat === undefined ? emptyStatStyle : statStyle}>
|
|
196
|
+
{stat ?? " "}
|
|
197
|
+
</span>
|
|
198
|
+
</span>
|
|
199
|
+
),
|
|
200
|
+
})}
|
|
201
|
+
</li>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { InlineSpinner, LoadingState } from "./LoadingState";
|
|
6
|
+
import { UiTextProvider } from "./uiText";
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
|
|
10
|
+
describe("LoadingState", () => {
|
|
11
|
+
it("is a status region with the default loading label", () => {
|
|
12
|
+
render(<LoadingState />);
|
|
13
|
+
|
|
14
|
+
expect(screen.getByRole("status")).toHaveTextContent("Loading...");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("resolves an explicit UiText label and honours string overrides", () => {
|
|
18
|
+
render(
|
|
19
|
+
<UiTextProvider strings={{ loading: "Laden…" }}>
|
|
20
|
+
<LoadingState />
|
|
21
|
+
<LoadingState label={{ id: "tasks.loading", message: "Loading tasks…" }} />
|
|
22
|
+
</UiTextProvider>,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const statuses = screen.getAllByRole("status");
|
|
26
|
+
expect(statuses[0]).toHaveTextContent("Laden…");
|
|
27
|
+
expect(statuses[1]).toHaveTextContent("Loading tasks…");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("InlineSpinner", () => {
|
|
32
|
+
it("renders a decorative svg hidden from assistive tech", () => {
|
|
33
|
+
const { container } = render(<InlineSpinner size={24} />);
|
|
34
|
+
|
|
35
|
+
const svg = container.querySelector("svg");
|
|
36
|
+
expect(svg).not.toBeNull();
|
|
37
|
+
expect(svg).toHaveAttribute("aria-hidden", "true");
|
|
38
|
+
expect(svg).toHaveAttribute("width", "24");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
import { injectTerpStyles } from "./styles";
|
|
4
|
+
import { useStrings, useUiText } from "./uiText";
|
|
5
|
+
import type { UiText } from "./uiText";
|
|
6
|
+
|
|
7
|
+
injectTerpStyles();
|
|
8
|
+
|
|
9
|
+
export interface InlineSpinnerProps {
|
|
10
|
+
/** Diameter in pixels; default 16. Inherits `currentColor`. */
|
|
11
|
+
size?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compact inline loading spinner for table cells, buttons, and tight layouts —
|
|
16
|
+
* just the spinning glyph, no label (it is hidden from assistive tech). Use
|
|
17
|
+
* {@link LoadingState} for a full loading block that announces itself.
|
|
18
|
+
* Rendered as an SVG so react-core takes no icon dependency; the rotation uses
|
|
19
|
+
* a CSS animation (respects `prefers-reduced-motion`).
|
|
20
|
+
*/
|
|
21
|
+
export function InlineSpinner({ size = 16 }: InlineSpinnerProps) {
|
|
22
|
+
return (
|
|
23
|
+
<span
|
|
24
|
+
aria-hidden="true"
|
|
25
|
+
data-terp="spinner-ring"
|
|
26
|
+
style={{
|
|
27
|
+
display: "inline-block",
|
|
28
|
+
verticalAlign: "middle",
|
|
29
|
+
width: size,
|
|
30
|
+
height: size,
|
|
31
|
+
lineHeight: 0,
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
<svg
|
|
35
|
+
aria-hidden="true"
|
|
36
|
+
width={size}
|
|
37
|
+
height={size}
|
|
38
|
+
viewBox="0 0 24 24"
|
|
39
|
+
fill="none"
|
|
40
|
+
style={{ display: "block" }}
|
|
41
|
+
>
|
|
42
|
+
<circle
|
|
43
|
+
cx="12"
|
|
44
|
+
cy="12"
|
|
45
|
+
r="10"
|
|
46
|
+
stroke="currentColor"
|
|
47
|
+
strokeOpacity="0.2"
|
|
48
|
+
strokeWidth="3"
|
|
49
|
+
/>
|
|
50
|
+
<path
|
|
51
|
+
d="M12 2a10 10 0 0 1 10 10"
|
|
52
|
+
stroke="currentColor"
|
|
53
|
+
strokeWidth="3"
|
|
54
|
+
strokeLinecap="round"
|
|
55
|
+
/>
|
|
56
|
+
</svg>
|
|
57
|
+
</span>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface LoadingStateProps {
|
|
62
|
+
/** Short description of what is loading; defaults to the `loading` string. */
|
|
63
|
+
label?: UiText;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const wrapStyle: CSSProperties = {
|
|
67
|
+
display: "flex",
|
|
68
|
+
alignItems: "center",
|
|
69
|
+
justifyContent: "center",
|
|
70
|
+
gap: "var(--space-2)",
|
|
71
|
+
padding: "var(--space-6)",
|
|
72
|
+
color: "var(--color-neutral-500)",
|
|
73
|
+
fontSize: "var(--font-size-sm)",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const spinnerColorStyle: CSSProperties = { color: "var(--color-brand-primary)" };
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Standard inline loading indicator: a spinner plus a short label, announced
|
|
80
|
+
* as a `status` live region. Use it when a query is pending and the page shell
|
|
81
|
+
* is already rendered; pairs with `Page`'s loading slot. Use {@link InlineSpinner}
|
|
82
|
+
* when only the glyph fits.
|
|
83
|
+
*/
|
|
84
|
+
export function LoadingState({ label }: LoadingStateProps) {
|
|
85
|
+
const strings = useStrings();
|
|
86
|
+
const resolve = useUiText();
|
|
87
|
+
return (
|
|
88
|
+
<div role="status" data-terp="loading-state" style={wrapStyle}>
|
|
89
|
+
<span style={spinnerColorStyle}>
|
|
90
|
+
<InlineSpinner size={20} />
|
|
91
|
+
</span>
|
|
92
|
+
<span>{resolve(label ?? strings.loading)}</span>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { LoginView } from "./LoginView";
|
|
6
|
+
import { TerpProvider } from "./TerpProvider";
|
|
7
|
+
|
|
8
|
+
function json(body: unknown, status = 200): Response {
|
|
9
|
+
return new Response(JSON.stringify(body), {
|
|
10
|
+
status,
|
|
11
|
+
headers: { "content-type": "application/json" },
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function stubFetch() {
|
|
16
|
+
// The provider probes /auth/refresh on mount; a 401 keeps the session signed out.
|
|
17
|
+
vi.stubGlobal(
|
|
18
|
+
"fetch",
|
|
19
|
+
vi.fn<typeof fetch>(async () => json({}, 401)),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
cleanup();
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("LoginView dev credentials", () => {
|
|
30
|
+
it("offers no fill button unless devCredentials is passed", async () => {
|
|
31
|
+
stubFetch();
|
|
32
|
+
render(
|
|
33
|
+
<TerpProvider baseUrl="https://api.test">
|
|
34
|
+
<LoginView />
|
|
35
|
+
</TerpProvider>,
|
|
36
|
+
);
|
|
37
|
+
await screen.findByRole("heading", { name: "Sign in" });
|
|
38
|
+
expect(screen.queryByRole("button", { name: "Fill dev credentials" })).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("fills the form with the dev credentials on click", async () => {
|
|
42
|
+
stubFetch();
|
|
43
|
+
render(
|
|
44
|
+
<TerpProvider baseUrl="https://api.test">
|
|
45
|
+
<LoginView
|
|
46
|
+
devCredentials={{
|
|
47
|
+
email: "admin@example.test",
|
|
48
|
+
password: "correct horse battery staple",
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
</TerpProvider>,
|
|
52
|
+
);
|
|
53
|
+
fireEvent.click(await screen.findByRole("button", { name: "Fill dev credentials" }));
|
|
54
|
+
expect(screen.getByPlaceholderText("Email")).toHaveValue("admin@example.test");
|
|
55
|
+
expect(screen.getByPlaceholderText("Password")).toHaveValue("correct horse battery staple");
|
|
56
|
+
});
|
|
57
|
+
});
|