@terpjs/react-core 0.6.1 → 0.8.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 +12 -2
- package/package.json +2 -2
- package/src/AppShell.test.tsx +33 -12
- package/src/AppShell.tsx +69 -249
- package/src/Breadcrumbs.test.tsx +24 -0
- package/src/Breadcrumbs.tsx +9 -32
- package/src/ConfirmDialog.tsx +13 -44
- package/src/EmptyState.tsx +8 -36
- package/src/ErrorState.tsx +8 -36
- package/src/Field.test.tsx +57 -0
- package/src/Field.tsx +46 -22
- package/src/HubPage.test.tsx +22 -13
- package/src/HubPage.tsx +25 -97
- package/src/LoadingState.tsx +3 -24
- package/src/ModuleNav.tsx +1 -1
- package/src/PageActions.tsx +5 -10
- package/src/UserMenu.test.tsx +12 -5
- package/src/UserMenu.tsx +33 -62
- package/src/dataview/DataView.test.tsx +109 -5
- package/src/dataview/DataView.tsx +41 -23
- package/src/dataview/DataViewCardList.tsx +14 -60
- package/src/dataview/DataViewColumnSettings.tsx +46 -51
- package/src/dataview/DataViewExpandableRow.tsx +2 -17
- package/src/dataview/DataViewPagination.tsx +2 -32
- package/src/dataview/DataViewRowActions.tsx +13 -33
- package/src/dataview/DataViewTable.tsx +16 -103
- package/src/dataview/DataViewToolbar.tsx +53 -76
- package/src/dataview/README.md +6 -0
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +4 -1
- package/src/dataview/types.ts +13 -0
- package/src/feedback.test.tsx +26 -0
- package/src/files.test.tsx +18 -0
- package/src/files.tsx +13 -4
- package/src/icons.test.tsx +10 -6
- package/src/icons.tsx +33 -37
- package/src/index.ts +0 -3
- package/src/layout.test.tsx +24 -9
- package/src/layout.tsx +24 -21
- package/src/layoutContract.test.tsx +95 -0
- package/src/locale.tsx +27 -4
- package/src/markers.test.ts +468 -0
- package/src/raw.d.ts +15 -1
- package/src/router.tsx +6 -9
- package/src/ssr.test.tsx +1 -3
- package/src/styles.test.ts +823 -6
- package/src/styles.ts +2699 -153
- package/src/theme.test.tsx +39 -0
- package/src/theme.themes.test.ts +124 -0
- package/src/theme.tsx +62 -14
- package/src/toast.tsx +35 -71
- package/src/tokens.guard.test.ts +3 -12
- package/src/ui/Alert.test.tsx +12 -0
- package/src/ui/Alert.tsx +15 -43
- package/src/ui/Badge.test.tsx +14 -3
- package/src/ui/Badge.tsx +13 -25
- package/src/ui/Button.test.tsx +17 -4
- package/src/ui/Button.tsx +10 -63
- package/src/ui/Card.test.tsx +6 -2
- package/src/ui/Card.tsx +11 -39
- package/src/ui/Checkbox.tsx +2 -19
- package/src/ui/Combobox.test.tsx +22 -0
- package/src/ui/Combobox.tsx +31 -80
- package/src/ui/DatePicker.test.tsx +131 -4
- package/src/ui/DatePicker.tsx +158 -106
- package/src/ui/Input.tsx +6 -19
- package/src/ui/Markdown.test.tsx +26 -0
- package/src/ui/Markdown.tsx +28 -2
- package/src/ui/Menu.test.tsx +38 -4
- package/src/ui/Menu.tsx +50 -52
- package/src/ui/Popover.tsx +53 -19
- package/src/ui/Radio.tsx +5 -30
- package/src/ui/Select.tsx +7 -30
- package/src/ui/Switch.tsx +2 -20
- package/src/ui/Tabs.tsx +4 -28
- package/src/ui/Textarea.tsx +6 -17
- package/src/ui/Tooltip.tsx +9 -21
- package/src/uiText.tsx +9 -0
- package/src/ui/controlStyles.ts +0 -9
package/src/HubPage.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
|
|
4
4
|
import { Page } from "./Page";
|
|
@@ -19,22 +19,15 @@ export type HubPageProps = Omit<
|
|
|
19
19
|
parents?: PageProps["breadcrumbs"];
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
const gridStyle: CSSProperties = {
|
|
23
|
-
display: "grid",
|
|
24
|
-
gridTemplateColumns: "repeat(auto-fit, minmax(min(16rem, 100%), 1fr))",
|
|
25
|
-
gridAutoRows: "1fr",
|
|
26
|
-
gap: "var(--space-4)",
|
|
27
|
-
alignItems: "stretch",
|
|
28
|
-
listStyle: "none",
|
|
29
|
-
margin: 0,
|
|
30
|
-
padding: 0,
|
|
31
|
-
};
|
|
32
|
-
|
|
33
22
|
/**
|
|
34
23
|
* The landing / hub page archetype: a `Page` whose body is a responsive grid of
|
|
35
24
|
* {@link HubCard} links into the sub-areas of a domain. Use it as a module index that
|
|
36
25
|
* adds discovery value (each card can carry a live `stat`, making the hub a lightweight
|
|
37
26
|
* dashboard) — never as a mandatory speed-bump in front of a single frequently-used list.
|
|
27
|
+
*
|
|
28
|
+
* It renders no inline styles: the grid and every part of a card take their geometry from
|
|
29
|
+
* the injected react-core sheet, matched on the `data-terp` markers stamped below
|
|
30
|
+
* (ADR 0094).
|
|
38
31
|
*/
|
|
39
32
|
export function HubPage({ children, parents, breadcrumbs, ...page }: HubPageProps) {
|
|
40
33
|
// The runtime half of the slot-typed layout contract control (ADR 0079) for the hub
|
|
@@ -61,7 +54,7 @@ export function HubPage({ children, parents, breadcrumbs, ...page }: HubPageProp
|
|
|
61
54
|
}
|
|
62
55
|
return (
|
|
63
56
|
<Page {...page} breadcrumbs={parents ?? breadcrumbs}>
|
|
64
|
-
<ul ref={gridRef}
|
|
57
|
+
<ul ref={gridRef} data-terp="hubpage-grid">
|
|
65
58
|
{children}
|
|
66
59
|
</ul>
|
|
67
60
|
</Page>
|
|
@@ -89,86 +82,23 @@ export interface HubCardProps {
|
|
|
89
82
|
renderLink?: RenderHubCardLink;
|
|
90
83
|
}
|
|
91
84
|
|
|
92
|
-
const cardStyle: CSSProperties = {
|
|
93
|
-
height: "100%",
|
|
94
|
-
minHeight: 0,
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
const cardBodyStyle: CSSProperties = {
|
|
98
|
-
display: "grid",
|
|
99
|
-
gridTemplateRows: "auto minmax(3rem, 1fr) auto",
|
|
100
|
-
gap: "var(--space-2)",
|
|
101
|
-
height: "100%",
|
|
102
|
-
minHeight: "10rem",
|
|
103
|
-
padding: "var(--space-4)",
|
|
104
|
-
border: "1px solid var(--color-neutral-200)",
|
|
105
|
-
borderRadius: "var(--radius-lg)",
|
|
106
|
-
background: "var(--color-neutral-0)",
|
|
107
|
-
color: "var(--color-neutral-900)",
|
|
108
|
-
boxSizing: "border-box",
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
const cardTitleRowStyle: CSSProperties = {
|
|
112
|
-
margin: 0,
|
|
113
|
-
display: "flex",
|
|
114
|
-
alignItems: "center",
|
|
115
|
-
gap: "var(--space-3)",
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
const iconTileStyle: CSSProperties = {
|
|
119
|
-
display: "inline-flex",
|
|
120
|
-
alignItems: "center",
|
|
121
|
-
justifyContent: "center",
|
|
122
|
-
width: "2.25rem",
|
|
123
|
-
height: "2.25rem",
|
|
124
|
-
flexShrink: 0,
|
|
125
|
-
borderRadius: "var(--radius-md)",
|
|
126
|
-
background: "var(--color-brand-primary-soft)",
|
|
127
|
-
color: "var(--color-brand-primary)",
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
const titleTextStyle: CSSProperties = {
|
|
131
|
-
color: "var(--color-neutral-900)",
|
|
132
|
-
fontSize: "var(--font-size-base)",
|
|
133
|
-
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
134
|
-
transition: "color 150ms ease",
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const descriptionStyle: CSSProperties = {
|
|
138
|
-
margin: 0,
|
|
139
|
-
color: "var(--color-neutral-600)",
|
|
140
|
-
fontSize: "var(--font-size-sm)",
|
|
141
|
-
lineHeight: 1.5,
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const emptyDescriptionStyle: CSSProperties = { ...descriptionStyle, visibility: "hidden" };
|
|
145
|
-
|
|
146
|
-
const statStyle: CSSProperties = {
|
|
147
|
-
color: "var(--color-neutral-900)",
|
|
148
|
-
fontSize: "var(--font-size-sm)",
|
|
149
|
-
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
150
|
-
};
|
|
151
|
-
const emptyStatStyle: CSSProperties = { ...statStyle, visibility: "hidden" };
|
|
152
|
-
|
|
153
|
-
const linkStyle: CSSProperties = {
|
|
154
|
-
textDecoration: "none",
|
|
155
|
-
color: "inherit",
|
|
156
|
-
display: "block",
|
|
157
|
-
height: "100%",
|
|
158
|
-
minHeight: 0,
|
|
159
|
-
};
|
|
160
|
-
|
|
161
85
|
const anchorRenderLink: RenderHubCardLink = ({ to, children }) => (
|
|
162
|
-
<a href={to} data-terp="hubcard-link"
|
|
86
|
+
<a href={to} data-terp="hubcard-link">
|
|
163
87
|
{children}
|
|
164
88
|
</a>
|
|
165
89
|
);
|
|
166
90
|
|
|
167
91
|
/**
|
|
168
|
-
* A single navigable card inside a {@link HubPage}: icon + title, a short
|
|
92
|
+
* A single navigable card inside a {@link HubPage}: icon + title, a short explanation of
|
|
169
93
|
* the area, and an optional live `stat`. The whole card is one link, rendered through
|
|
170
94
|
* `renderLink` so the hub stays router-agnostic — defaulting to the surrounding router's
|
|
171
95
|
* `Link`, and to a plain anchor only outside a Terp router.
|
|
96
|
+
*
|
|
97
|
+
* The description and stat rows always render, carrying a non-breaking space when the prop
|
|
98
|
+
* is absent, so a card's three grid rows keep their heights and a bare card stays flush
|
|
99
|
+
* with a full one in the same row. `data-empty` is what hides the placeholder — an
|
|
100
|
+
* attribute rather than a second style object, because "is this row a placeholder" is a
|
|
101
|
+
* fact about the element that a rule can read.
|
|
172
102
|
*/
|
|
173
103
|
export function HubCard({
|
|
174
104
|
to,
|
|
@@ -184,27 +114,26 @@ export function HubCard({
|
|
|
184
114
|
(navLink === null
|
|
185
115
|
? anchorRenderLink
|
|
186
116
|
: ({ to: href, children }: { to: string; children: ReactNode }) => (
|
|
187
|
-
<span data-terp="hubcard-link"
|
|
188
|
-
{navLink({ to: href, children })}
|
|
189
|
-
</span>
|
|
117
|
+
<span data-terp="hubcard-link">{navLink({ to: href, children })}</span>
|
|
190
118
|
));
|
|
191
119
|
const resolve = useUiText();
|
|
192
120
|
return (
|
|
193
|
-
<li data-terp="hubcard"
|
|
121
|
+
<li data-terp="hubcard">
|
|
194
122
|
{renderCardLink({
|
|
195
123
|
to,
|
|
196
124
|
children: (
|
|
197
|
-
<span data-terp="hubcard-body"
|
|
198
|
-
<span
|
|
199
|
-
{icon !== undefined && <span
|
|
200
|
-
<strong data-terp="hubcard-title"
|
|
201
|
-
{resolve(title)}
|
|
202
|
-
</strong>
|
|
125
|
+
<span data-terp="hubcard-body">
|
|
126
|
+
<span data-terp="hubcard-heading">
|
|
127
|
+
{icon !== undefined && <span data-terp="hubcard-icon">{icon}</span>}
|
|
128
|
+
<strong data-terp="hubcard-title">{resolve(title)}</strong>
|
|
203
129
|
</span>
|
|
204
|
-
<span
|
|
130
|
+
<span
|
|
131
|
+
data-terp="hubcard-description"
|
|
132
|
+
data-empty={description === undefined ? "true" : undefined}
|
|
133
|
+
>
|
|
205
134
|
{description === undefined ? " " : resolve(description)}
|
|
206
135
|
</span>
|
|
207
|
-
<span data-terp="hubcard-stat"
|
|
136
|
+
<span data-terp="hubcard-stat" data-empty={stat === undefined ? "true" : undefined}>
|
|
208
137
|
{stat ?? " "}
|
|
209
138
|
</span>
|
|
210
139
|
</span>
|
|
@@ -213,4 +142,3 @@ export function HubCard({
|
|
|
213
142
|
</li>
|
|
214
143
|
);
|
|
215
144
|
}
|
|
216
|
-
|
package/src/LoadingState.tsx
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { CSSProperties } from "react";
|
|
2
|
-
|
|
3
1
|
import { injectTerpStyles } from "./styles";
|
|
4
2
|
import { useStrings, useUiText } from "./uiText";
|
|
5
3
|
import type { UiText } from "./uiText";
|
|
@@ -23,13 +21,7 @@ export function InlineSpinner({ size = 16 }: InlineSpinnerProps) {
|
|
|
23
21
|
<span
|
|
24
22
|
aria-hidden="true"
|
|
25
23
|
data-terp="spinner-ring"
|
|
26
|
-
style={{
|
|
27
|
-
display: "inline-block",
|
|
28
|
-
verticalAlign: "middle",
|
|
29
|
-
width: size,
|
|
30
|
-
height: size,
|
|
31
|
-
lineHeight: 0,
|
|
32
|
-
}}
|
|
24
|
+
style={{ width: size, height: size }}
|
|
33
25
|
>
|
|
34
26
|
<svg
|
|
35
27
|
aria-hidden="true"
|
|
@@ -37,7 +29,6 @@ export function InlineSpinner({ size = 16 }: InlineSpinnerProps) {
|
|
|
37
29
|
height={size}
|
|
38
30
|
viewBox="0 0 24 24"
|
|
39
31
|
fill="none"
|
|
40
|
-
style={{ display: "block" }}
|
|
41
32
|
>
|
|
42
33
|
<circle
|
|
43
34
|
cx="12"
|
|
@@ -63,18 +54,6 @@ export interface LoadingStateProps {
|
|
|
63
54
|
label?: UiText;
|
|
64
55
|
}
|
|
65
56
|
|
|
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
57
|
/**
|
|
79
58
|
* Standard inline loading indicator: a spinner plus a short label, announced
|
|
80
59
|
* as a `status` live region. Use it when a query is pending and the page shell
|
|
@@ -85,8 +64,8 @@ export function LoadingState({ label }: LoadingStateProps) {
|
|
|
85
64
|
const strings = useStrings();
|
|
86
65
|
const resolve = useUiText();
|
|
87
66
|
return (
|
|
88
|
-
<div role="status" data-terp="loading-state"
|
|
89
|
-
<span
|
|
67
|
+
<div role="status" data-terp="loading-state">
|
|
68
|
+
<span data-terp="loading-state-spinner">
|
|
90
69
|
<InlineSpinner size={20} />
|
|
91
70
|
</span>
|
|
92
71
|
<span>{resolve(label ?? strings.loading)}</span>
|
package/src/ModuleNav.tsx
CHANGED
package/src/PageActions.tsx
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
|
+
import { injectTerpStyles } from "./styles";
|
|
3
4
|
import { Menu, MenuItem } from "./ui/Menu";
|
|
4
5
|
import { useStrings } from "./uiText";
|
|
5
6
|
import type { UiText } from "./uiText";
|
|
6
7
|
|
|
8
|
+
injectTerpStyles();
|
|
9
|
+
|
|
7
10
|
export interface OverflowAction {
|
|
8
11
|
/** Display label for the menu item. */
|
|
9
12
|
label: UiText;
|
|
@@ -25,14 +28,6 @@ export interface PageActionsProps {
|
|
|
25
28
|
className?: string;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
const clusterStyle: CSSProperties = {
|
|
29
|
-
display: "flex",
|
|
30
|
-
flexWrap: "wrap",
|
|
31
|
-
alignItems: "center",
|
|
32
|
-
justifyContent: "flex-end",
|
|
33
|
-
gap: "var(--space-2)",
|
|
34
|
-
};
|
|
35
|
-
|
|
36
31
|
/** Standard right-aligned action cluster for page headers. */
|
|
37
32
|
export function PageActions({ primary, secondary, overflow, className }: PageActionsProps) {
|
|
38
33
|
const strings = useStrings();
|
|
@@ -43,7 +38,7 @@ export function PageActions({ primary, secondary, overflow, className }: PageAct
|
|
|
43
38
|
}
|
|
44
39
|
|
|
45
40
|
return (
|
|
46
|
-
<div className={className}
|
|
41
|
+
<div className={className} data-terp="page-actions">
|
|
47
42
|
{hasOverflow && (
|
|
48
43
|
<Menu trigger="⋯" triggerLabel={strings.moreActions}>
|
|
49
44
|
{({ close }) => (
|
package/src/UserMenu.test.tsx
CHANGED
|
@@ -121,13 +121,20 @@ describe("UserMenu", () => {
|
|
|
121
121
|
const trigger = await screen.findByRole("button", { name: "Account menu" });
|
|
122
122
|
expect(screen.getByText("JD")).toBeInTheDocument();
|
|
123
123
|
expect(screen.queryByText("jane.doe@example.com")).not.toBeInTheDocument();
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
// The collapsed geometry is a sheet rule now (ADR 0094), so what this asserts is the
|
|
125
|
+
// attribute the rule keys on and the absence of the inline object it replaced. The
|
|
126
|
+
// variant sits on the component's ROOT rather than on the trigger, because the root is
|
|
127
|
+
// what UserMenu names — the trigger is reached by descending from it.
|
|
128
|
+
const root = trigger.closest('[data-terp="user-menu"]');
|
|
129
|
+
expect(root).not.toBeNull();
|
|
130
|
+
expect(root).toHaveAttribute("data-variant", "collapsed");
|
|
131
|
+
expect(trigger).toHaveAttribute("data-terp", "menu-trigger");
|
|
132
|
+
expect(trigger.getAttribute("style")).toBeNull();
|
|
133
|
+
// The identity still surfaces inside the opened panel, whose geometry is keyed on the
|
|
134
|
+
// owner attribute — the panel is portalled out, so nothing else could reach it.
|
|
129
135
|
fireEvent.click(trigger);
|
|
130
136
|
expect(screen.getByText("jane.doe@example.com")).toBeInTheDocument();
|
|
137
|
+
expect(screen.getByRole("menu").parentElement).toHaveAttribute("data-owner", "user-menu");
|
|
131
138
|
});
|
|
132
139
|
|
|
133
140
|
it("signs out via the menu (revokes the token server-side)", async () => {
|
package/src/UserMenu.tsx
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type { CSSProperties } from "react";
|
|
2
|
-
|
|
3
1
|
import { Icon } from "./icons";
|
|
2
|
+
import { injectTerpStyles } from "./styles";
|
|
4
3
|
import { useAuth } from "./TerpProvider";
|
|
5
4
|
import { Menu, MenuItem } from "./ui/Menu";
|
|
6
|
-
import { CONTROL_TEXT_STYLE } from "./ui/controlStyles";
|
|
7
5
|
import { useStrings } from "./uiText";
|
|
8
6
|
|
|
7
|
+
injectTerpStyles();
|
|
8
|
+
|
|
9
9
|
/** Initials for the avatar: the first letters of the email's local-part words. */
|
|
10
10
|
export function userInitials(email: string): string {
|
|
11
11
|
const local = email.split("@")[0] ?? "";
|
|
@@ -14,65 +14,22 @@ export function userInitials(email: string): string {
|
|
|
14
14
|
return initials.join("") || "?";
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
const triggerStyle: CSSProperties = {
|
|
18
|
-
...CONTROL_TEXT_STYLE,
|
|
19
|
-
display: "flex",
|
|
20
|
-
alignItems: "center",
|
|
21
|
-
justifyContent: "flex-start",
|
|
22
|
-
gap: "var(--space-2)",
|
|
23
|
-
width: "100%",
|
|
24
|
-
boxSizing: "border-box",
|
|
25
|
-
padding: "var(--space-2)",
|
|
26
|
-
textAlign: "left",
|
|
27
|
-
color: "var(--color-neutral-900)",
|
|
28
|
-
background: "transparent",
|
|
29
|
-
border: "1px solid transparent",
|
|
30
|
-
borderRadius: "var(--radius-md)",
|
|
31
|
-
cursor: "pointer",
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const collapsedTriggerStyle: CSSProperties = {
|
|
35
|
-
justifyContent: "center",
|
|
36
|
-
gap: 0,
|
|
37
|
-
padding: 0,
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const avatarStyle: CSSProperties = {
|
|
41
|
-
display: "inline-flex",
|
|
42
|
-
alignItems: "center",
|
|
43
|
-
justifyContent: "center",
|
|
44
|
-
width: "2rem",
|
|
45
|
-
height: "2rem",
|
|
46
|
-
flexShrink: 0,
|
|
47
|
-
borderRadius: "var(--radius-full)",
|
|
48
|
-
background: "var(--color-brand-primary)",
|
|
49
|
-
color: "var(--color-brand-primary-contrast)",
|
|
50
|
-
fontSize: "var(--font-size-sm)",
|
|
51
|
-
fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const identityStyle: CSSProperties = { display: "grid", minWidth: 0, fontSize: "var(--font-size-sm)" };
|
|
55
|
-
const emailStyle: CSSProperties = { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" };
|
|
56
|
-
const roleStyle: CSSProperties = { color: "var(--color-neutral-600)" };
|
|
57
|
-
const panelIdentityStyle: CSSProperties = {
|
|
58
|
-
display: "grid",
|
|
59
|
-
gap: "var(--space-1)",
|
|
60
|
-
padding: "var(--space-2)",
|
|
61
|
-
marginBottom: "var(--space-1)",
|
|
62
|
-
borderBottom: "1px solid var(--color-neutral-200)",
|
|
63
|
-
fontSize: "var(--font-size-sm)",
|
|
64
|
-
overflowWrap: "anywhere",
|
|
65
|
-
};
|
|
66
|
-
|
|
67
17
|
export interface UserMenuProps {
|
|
68
18
|
/** Icon-rail mode: show only the avatar on the trigger (the shell's collapsed state). */
|
|
69
19
|
collapsed?: boolean;
|
|
70
20
|
/** Opens the settings / profile page; rendered as the menu's first item when provided. */
|
|
71
21
|
onSettings?: () => void;
|
|
22
|
+
/**
|
|
23
|
+
* Open the panel on mount (uncontrolled), the shape every other disclosure in the package
|
|
24
|
+
* takes. It is also the only way to render the panel deterministically, and the panel is
|
|
25
|
+
* where this component's own geometry lives — so without it those rules ship unpainted by
|
|
26
|
+
* either visual lane, which is the state the calendar was in for two stages.
|
|
27
|
+
*/
|
|
28
|
+
defaultOpen?: boolean;
|
|
72
29
|
}
|
|
73
30
|
|
|
74
31
|
/** The signed-in user's account menu. */
|
|
75
|
-
export function UserMenu({ collapsed = false, onSettings }: UserMenuProps = {}) {
|
|
32
|
+
export function UserMenu({ collapsed = false, onSettings, defaultOpen }: UserMenuProps = {}) {
|
|
76
33
|
const auth = useAuth();
|
|
77
34
|
const strings = useStrings();
|
|
78
35
|
const user = auth.currentUser();
|
|
@@ -82,11 +39,11 @@ export function UserMenu({ collapsed = false, onSettings }: UserMenuProps = {})
|
|
|
82
39
|
|
|
83
40
|
const trigger = (
|
|
84
41
|
<>
|
|
85
|
-
<span aria-hidden="true"
|
|
42
|
+
<span aria-hidden="true" data-terp="user-menu-avatar">{userInitials(user.email)}</span>
|
|
86
43
|
{!collapsed && (
|
|
87
|
-
<span
|
|
88
|
-
<span
|
|
89
|
-
<span
|
|
44
|
+
<span data-terp="user-menu-identity">
|
|
45
|
+
<span data-terp="user-menu-email">{user.email}</span>
|
|
46
|
+
<span data-terp="user-menu-role">{user.role_name}</span>
|
|
90
47
|
</span>
|
|
91
48
|
)}
|
|
92
49
|
</>
|
|
@@ -98,14 +55,28 @@ export function UserMenu({ collapsed = false, onSettings }: UserMenuProps = {})
|
|
|
98
55
|
triggerLabel={strings.accountMenu}
|
|
99
56
|
placement="top"
|
|
100
57
|
align="start"
|
|
101
|
-
|
|
102
|
-
|
|
58
|
+
defaultOpen={defaultOpen}
|
|
59
|
+
// This component's rendered root is Menu's popover wrapper — it adds no element of its
|
|
60
|
+
// own — so it names that root through Menu. The icon-rail mode is a variant of the same
|
|
61
|
+
// component rather than a different one, so it is data-variant on the same marker; and
|
|
62
|
+
// the trigger and the panel are then reachable from the sheet, which is what let both
|
|
63
|
+
// `triggerStyle` and `panelStyle` be deleted. The panel needs the owner attribute
|
|
64
|
+
// Popover stamps for it: it is portalled to document.body, so no descendant selector
|
|
65
|
+
// from this side of the tree could ever reach it.
|
|
66
|
+
data-terp="user-menu"
|
|
67
|
+
data-variant={collapsed ? "collapsed" : undefined}
|
|
68
|
+
data-owner="user-menu"
|
|
103
69
|
>
|
|
104
70
|
{({ close }) => (
|
|
105
71
|
<>
|
|
106
|
-
|
|
72
|
+
{/* role="group": a role="menu" may own only menuitem / menuitemradio /
|
|
73
|
+
menuitemcheckbox / group / separator, and this identity block is a plain div —
|
|
74
|
+
so in menu mode assistive tech had no valid reason to reach it. A group needs no
|
|
75
|
+
accessible name to be valid and has no default presentation, so this is an ARIA
|
|
76
|
+
correction with no visual and no new UI string. */}
|
|
77
|
+
<div role="group" data-terp="user-menu-header">
|
|
107
78
|
<span>{user.email}</span>
|
|
108
|
-
<span
|
|
79
|
+
<span data-terp="user-menu-role">{user.role_name}</span>
|
|
109
80
|
</div>
|
|
110
81
|
{onSettings !== undefined && (
|
|
111
82
|
<MenuItem
|
|
@@ -83,6 +83,32 @@ describe("DataView states", () => {
|
|
|
83
83
|
const untinted = screen.getByText("Broken printer").closest("tr");
|
|
84
84
|
expect(untinted).not.toHaveAttribute("data-tone");
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
it("claims data-clickable only when a row click is wired up", async () => {
|
|
88
|
+
// The other half of a sheet rule no visual lane can see. The row marker is stamped
|
|
89
|
+
// unconditionally — it has to be, or a toned row that is not clickable carries data-tone on
|
|
90
|
+
// an element no selector reaches — so what separates a row Enter will open from a row that
|
|
91
|
+
// merely contains a focusable checkbox is this attribute alone. The sheet's focus-within
|
|
92
|
+
// tint is keyed on it, and styles.test.ts pins that end.
|
|
93
|
+
const { unmount } = render(
|
|
94
|
+
<DataView repository={inMemoryRepo()} columns={COLUMNS} enableSelection />,
|
|
95
|
+
);
|
|
96
|
+
const inert = (await screen.findByText("VPN access")).closest("tr");
|
|
97
|
+
expect(inert).toHaveAttribute("data-terp", "dataview-row");
|
|
98
|
+
expect(inert).not.toHaveAttribute("data-clickable");
|
|
99
|
+
unmount();
|
|
100
|
+
|
|
101
|
+
render(
|
|
102
|
+
<DataView
|
|
103
|
+
repository={inMemoryRepo()}
|
|
104
|
+
columns={COLUMNS}
|
|
105
|
+
getRowLabel={(t) => t.title}
|
|
106
|
+
onRowClick={() => {}}
|
|
107
|
+
/>,
|
|
108
|
+
);
|
|
109
|
+
const clickable = (await screen.findByText("VPN access")).closest("tr");
|
|
110
|
+
expect(clickable).toHaveAttribute("data-clickable", "true");
|
|
111
|
+
});
|
|
86
112
|
});
|
|
87
113
|
|
|
88
114
|
describe("DataView server-side mode", () => {
|
|
@@ -365,20 +391,40 @@ describe("DataView search and view options", () => {
|
|
|
365
391
|
expect(await screen.findByText("Broken printer")).toBeInTheDocument();
|
|
366
392
|
});
|
|
367
393
|
|
|
368
|
-
it("hides and reorders columns from the view-options
|
|
394
|
+
it("hides and reorders columns from the view-options panel", async () => {
|
|
369
395
|
render(<DataView repository={inMemoryRepo()} columns={COLUMNS} />);
|
|
370
396
|
await screen.findByText("Broken printer");
|
|
371
397
|
|
|
372
398
|
fireEvent.click(screen.getByRole("button", { name: "View options" }));
|
|
373
|
-
|
|
374
|
-
|
|
399
|
+
// A group labelled by its own heading, not a menu. That is the fix for a critical
|
|
400
|
+
// aria-required-children violation: role="menu" may own only menuitem-family children,
|
|
401
|
+
// and this panel's content is a heading plus labelled checkboxes plus paired reorder
|
|
402
|
+
// buttons — a form. The guard below is the regression test for it, because the violation
|
|
403
|
+
// was invisible for as long as nothing rendered the panel open.
|
|
404
|
+
const panel = screen.getByRole("group", { name: "Columns" });
|
|
405
|
+
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
|
406
|
+
fireEvent.click(within(panel).getByRole("checkbox", { name: "Status" }));
|
|
375
407
|
expect(screen.queryByRole("columnheader", { name: /Status/ })).not.toBeInTheDocument();
|
|
376
408
|
|
|
377
|
-
fireEvent.click(within(
|
|
378
|
-
fireEvent.click(within(
|
|
409
|
+
fireEvent.click(within(panel).getByRole("checkbox", { name: "Status" }));
|
|
410
|
+
fireEvent.click(within(panel).getByRole("button", { name: "Move up: Status" }));
|
|
379
411
|
const headers = screen.getAllByRole("columnheader").map((th) => th.textContent);
|
|
380
412
|
expect(headers[0]).toContain("Status");
|
|
381
413
|
});
|
|
414
|
+
|
|
415
|
+
it("names the view-options trigger from its visible label, not an override", async () => {
|
|
416
|
+
// The trigger used to take its accessible name from an aria-label the menu primitive
|
|
417
|
+
// applied, which is the shape that hides a visible label from assistive tech when the two
|
|
418
|
+
// drift. It is now named by its own content, so the announced name IS the rendered one.
|
|
419
|
+
render(<DataView repository={inMemoryRepo()} columns={COLUMNS} />);
|
|
420
|
+
await screen.findByText("Broken printer");
|
|
421
|
+
const trigger = screen.getByRole("button", { name: "View options" });
|
|
422
|
+
expect(trigger).not.toHaveAttribute("aria-label");
|
|
423
|
+
expect(trigger).toHaveTextContent("View options");
|
|
424
|
+
// A disclosure, so this is the whole contract: expanded state plus the panel it controls.
|
|
425
|
+
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
|
426
|
+
expect(trigger).not.toHaveAttribute("aria-haspopup");
|
|
427
|
+
});
|
|
382
428
|
});
|
|
383
429
|
|
|
384
430
|
describe("DataView embedded variant", () => {
|
|
@@ -404,3 +450,61 @@ describe("DataView embedded variant", () => {
|
|
|
404
450
|
expect(document.querySelector('[data-terp="dataview-toolbar"]')).not.toBeInTheDocument();
|
|
405
451
|
});
|
|
406
452
|
});
|
|
453
|
+
|
|
454
|
+
describe("DataView search scope", () => {
|
|
455
|
+
// Nothing rendered this branch before the search-scope specimen existed: the control is
|
|
456
|
+
// behind `search.trim() !== ""`, and every other test and specimen starts with an empty box.
|
|
457
|
+
// That is how a control shipped announcing itself as a toggle button whose state is also its
|
|
458
|
+
// label.
|
|
459
|
+
const scopedRepo = (): DataViewRepository<Ticket> => ({
|
|
460
|
+
query: async () => ({ rows: TICKETS, totalCount: TICKETS.length }),
|
|
461
|
+
getRowId: (ticket) => ticket.id,
|
|
462
|
+
capabilities: { serverSide: true, search: true, searchScope: true },
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("swaps the label rather than claiming to be a toggle button", async () => {
|
|
466
|
+
const onBroadenedChange = vi.fn();
|
|
467
|
+
const { rerender } = render(
|
|
468
|
+
<DataView
|
|
469
|
+
repository={scopedRepo()}
|
|
470
|
+
columns={COLUMNS}
|
|
471
|
+
searchScope={{
|
|
472
|
+
broadened: false,
|
|
473
|
+
onBroadenedChange,
|
|
474
|
+
label: "Search everything",
|
|
475
|
+
broadenedLabel: "Searching everything",
|
|
476
|
+
}}
|
|
477
|
+
/>,
|
|
478
|
+
);
|
|
479
|
+
await screen.findByText("Broken printer");
|
|
480
|
+
// The control needs a search term, so type one.
|
|
481
|
+
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "printer" } });
|
|
482
|
+
const narrow = await screen.findByRole("button", { name: "Search everything" });
|
|
483
|
+
// The state is the label. Encoding it a second time in aria-pressed announces it twice
|
|
484
|
+
// and lets the two disagree, because both labels are caller-supplied.
|
|
485
|
+
expect(narrow).not.toHaveAttribute("aria-pressed");
|
|
486
|
+
fireEvent.click(narrow);
|
|
487
|
+
expect(onBroadenedChange).toHaveBeenCalledWith(true);
|
|
488
|
+
|
|
489
|
+
rerender(
|
|
490
|
+
<DataView
|
|
491
|
+
repository={scopedRepo()}
|
|
492
|
+
columns={COLUMNS}
|
|
493
|
+
searchScope={{
|
|
494
|
+
broadened: true,
|
|
495
|
+
onBroadenedChange,
|
|
496
|
+
label: "Search everything",
|
|
497
|
+
broadenedLabel: "Searching everything",
|
|
498
|
+
}}
|
|
499
|
+
/>,
|
|
500
|
+
);
|
|
501
|
+
const broad = await screen.findByRole("button", { name: "Searching everything" });
|
|
502
|
+
expect(broad).not.toHaveAttribute("aria-pressed");
|
|
503
|
+
// And the attribute is now this component's alone on the two layout toggles, which is the
|
|
504
|
+
// enumeration the shared icon-button hover guard is safe by.
|
|
505
|
+
expect(document.querySelectorAll("[aria-pressed]")).toHaveLength(2);
|
|
506
|
+
for (const toggle of document.querySelectorAll("[aria-pressed]")) {
|
|
507
|
+
expect(toggle.getAttribute("data-terp")).toBe("iconbutton");
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
});
|