@terpjs/react-core 0.7.0 → 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/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/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 +9 -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 +24 -4
- package/src/markers.test.ts +242 -19
- 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.tsx +24 -3
- package/src/toast.tsx +35 -71
- 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 +9 -28
- 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/ui/controlStyles.ts +0 -9
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
|
+
});
|
|
@@ -17,6 +17,7 @@ import { DataViewTextProvider, useDataViewText } from "./internal";
|
|
|
17
17
|
import type {
|
|
18
18
|
DataViewBatchAction,
|
|
19
19
|
DataViewColumn,
|
|
20
|
+
DataViewDensity,
|
|
20
21
|
DataViewQuery,
|
|
21
22
|
DataViewRepository,
|
|
22
23
|
DataViewRowAction,
|
|
@@ -39,6 +40,21 @@ interface DataViewBaseProps<T> {
|
|
|
39
40
|
columns: DataViewColumn<T>[];
|
|
40
41
|
/** "embedded" renders a plain compact view: no view toggle, no page-size selector, no footer. */
|
|
41
42
|
variant?: "full" | "embedded";
|
|
43
|
+
/**
|
|
44
|
+
* How tightly this view packs its cells and controls (default `"comfortable"`).
|
|
45
|
+
*
|
|
46
|
+
* `"compact"` stamps `data-density="compact"` on the root, which re-scopes the live
|
|
47
|
+
* density tokens for the whole subtree — cell padding here, and the control heights
|
|
48
|
+
* `Button`, `Input` and `Select` already read, so the toolbar tightens with the table.
|
|
49
|
+
*
|
|
50
|
+
* `"comfortable"` stamps NO attribute, because comfortable IS the token sheet's
|
|
51
|
+
* `:root` value and an attribute for it would match no rule. The consequence worth
|
|
52
|
+
* knowing: inside an already-compact subtree, `density="comfortable"` does not make
|
|
53
|
+
* this view comfortable again. Expressing that needs a named comfortable copy of
|
|
54
|
+
* each live token, which ADR 0094 defers until something asks — nothing can ask
|
|
55
|
+
* until the shell takes a density of its own.
|
|
56
|
+
*/
|
|
57
|
+
density?: DataViewDensity;
|
|
42
58
|
enableSelection?: boolean;
|
|
43
59
|
batchActions?: DataViewBatchAction<T>[];
|
|
44
60
|
rowActions?: (row: T) => DataViewRowAction<T>[];
|
|
@@ -130,6 +146,13 @@ function useIsMobile(): boolean {
|
|
|
130
146
|
function DataViewInner<T>(props: DataViewProps<T>) {
|
|
131
147
|
const { strings, resolve } = useDataViewText();
|
|
132
148
|
const embedded = props.variant === "embedded";
|
|
149
|
+
// Only the compact value is expressible as an attribute; see the prop's doc comment.
|
|
150
|
+
const densityAttribute = props.density === "compact" ? "compact" : undefined;
|
|
151
|
+
// Hoisted rather than written inline at the two roots, which is what the density
|
|
152
|
+
// attribute above already does and what keeps the marker scanner out of a trap: it
|
|
153
|
+
// reads a whole expression container, so every string literal inside one counts as a
|
|
154
|
+
// marker name and a conditional written at the attribute would report phantoms.
|
|
155
|
+
const variantAttribute = embedded ? "embedded" : "full";
|
|
133
156
|
const serverSide = props.repository.capabilities.serverSide;
|
|
134
157
|
const initialPageSize = embedded
|
|
135
158
|
? EMBEDDED_PAGE_SIZE
|
|
@@ -296,7 +319,7 @@ function DataViewInner<T>(props: DataViewProps<T>) {
|
|
|
296
319
|
return props.renderError !== undefined ? (
|
|
297
320
|
<>{props.renderError(error)}</>
|
|
298
321
|
) : (
|
|
299
|
-
<div
|
|
322
|
+
<div data-terp="dataview-error">
|
|
300
323
|
<ErrorState title={strings.errorTitle} error={error} />
|
|
301
324
|
</div>
|
|
302
325
|
);
|
|
@@ -325,7 +348,7 @@ function DataViewInner<T>(props: DataViewProps<T>) {
|
|
|
325
348
|
);
|
|
326
349
|
}
|
|
327
350
|
return (
|
|
328
|
-
<div
|
|
351
|
+
<div data-terp="dataview-scroll">
|
|
329
352
|
<DataViewTable
|
|
330
353
|
rows={rows}
|
|
331
354
|
columns={state.visibleColumns}
|
|
@@ -366,7 +389,7 @@ function DataViewInner<T>(props: DataViewProps<T>) {
|
|
|
366
389
|
props.toolbarContent !== undefined ||
|
|
367
390
|
props.toolbarTrailing !== undefined;
|
|
368
391
|
return (
|
|
369
|
-
<div data-terp="dataview"
|
|
392
|
+
<div data-terp="dataview" data-variant={variantAttribute} data-density={densityAttribute}>
|
|
370
393
|
{showsEmbeddedToolbar && (
|
|
371
394
|
<DataViewToolbar<T>
|
|
372
395
|
searchEnabled={props.repository.capabilities.search}
|
|
@@ -401,15 +424,7 @@ function DataViewInner<T>(props: DataViewProps<T>) {
|
|
|
401
424
|
}
|
|
402
425
|
|
|
403
426
|
return (
|
|
404
|
-
<div
|
|
405
|
-
data-terp="dataview"
|
|
406
|
-
style={{
|
|
407
|
-
display: "grid",
|
|
408
|
-
background: "var(--color-neutral-0)",
|
|
409
|
-
border: "1px solid var(--color-neutral-200)",
|
|
410
|
-
borderRadius: "var(--radius-lg)",
|
|
411
|
-
}}
|
|
412
|
-
>
|
|
427
|
+
<div data-terp="dataview" data-variant={variantAttribute} data-density={densityAttribute}>
|
|
413
428
|
<DataViewToolbar<T>
|
|
414
429
|
searchEnabled={props.repository.capabilities.search}
|
|
415
430
|
search={state.search}
|
|
@@ -458,20 +473,23 @@ function DataViewInner<T>(props: DataViewProps<T>) {
|
|
|
458
473
|
);
|
|
459
474
|
}
|
|
460
475
|
|
|
461
|
-
/**
|
|
476
|
+
/**
|
|
477
|
+
* Fixed-height placeholder rows so the initial load never causes a layout jump.
|
|
478
|
+
*
|
|
479
|
+
* One consequence of taking those heights from the sheet is worth stating where it
|
|
480
|
+
* bites, because it is the sharpest instance of something the whole migration accepted
|
|
481
|
+
* rather than anything peculiar to this component: the style injector is guarded on
|
|
482
|
+
* `document`, so no migrated rule reaches server-rendered HTML. A server-rendered
|
|
483
|
+
* DataView emits its toolbar and this skeleton and nothing else, which makes this the
|
|
484
|
+
* one place where "no layout jump" is the entire reason the markup exists. Until
|
|
485
|
+
* hydration these five divs are now zero-height. `ssr.test.tsx` asserts the marker
|
|
486
|
+
* string only, so nothing fails — it is a trade, recorded, not an oversight.
|
|
487
|
+
*/
|
|
462
488
|
function DataViewSkeleton({ label }: { label: string }) {
|
|
463
489
|
return (
|
|
464
|
-
<div role="status" aria-label={label}
|
|
490
|
+
<div role="status" aria-label={label} data-terp="dataview-skeleton">
|
|
465
491
|
{Array.from({ length: 5 }, (_, index) => (
|
|
466
|
-
<div
|
|
467
|
-
key={index}
|
|
468
|
-
aria-hidden
|
|
469
|
-
style={{
|
|
470
|
-
height: "2.75rem",
|
|
471
|
-
background: "var(--color-neutral-100)",
|
|
472
|
-
borderRadius: "var(--radius-md)",
|
|
473
|
-
}}
|
|
474
|
-
/>
|
|
492
|
+
<div key={index} aria-hidden />
|
|
475
493
|
))}
|
|
476
494
|
</div>
|
|
477
495
|
);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
import type { BadgeTone } from "../ui/Badge";
|
|
4
|
-
import { toneSoftColors } from "../ui/Badge";
|
|
5
4
|
import type { UiText } from "../uiText";
|
|
6
5
|
|
|
7
6
|
import { DataViewExpandToggle } from "./DataViewExpandableRow";
|
|
@@ -30,18 +29,6 @@ export interface DataViewCardListProps<T> {
|
|
|
30
29
|
rowActions?: (row: T) => DataViewRowAction<T>[];
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
const recordButtonStyle: CSSProperties = {
|
|
34
|
-
position: "absolute",
|
|
35
|
-
width: 1,
|
|
36
|
-
height: 1,
|
|
37
|
-
padding: 0,
|
|
38
|
-
margin: -1,
|
|
39
|
-
overflow: "hidden",
|
|
40
|
-
clip: "rect(0 0 0 0)",
|
|
41
|
-
whiteSpace: "nowrap",
|
|
42
|
-
border: 0,
|
|
43
|
-
};
|
|
44
|
-
|
|
45
32
|
function slotValue<T>(
|
|
46
33
|
columns: DataViewColumn<T>[],
|
|
47
34
|
row: T,
|
|
@@ -68,7 +55,7 @@ export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
|
|
|
68
55
|
const { strings, resolve, format } = useDataViewText();
|
|
69
56
|
|
|
70
57
|
return (
|
|
71
|
-
<ul
|
|
58
|
+
<ul data-terp="dataview-card-list">
|
|
72
59
|
{props.rows.map((row) => {
|
|
73
60
|
const rowId = props.getRowId(row);
|
|
74
61
|
const expanded = props.isExpanded(rowId);
|
|
@@ -78,18 +65,9 @@ export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
|
|
|
78
65
|
<li key={rowId}>
|
|
79
66
|
<div
|
|
80
67
|
onClick={clickable ? () => props.onRowClick?.(row) : undefined}
|
|
81
|
-
data-terp=
|
|
68
|
+
data-terp="dataview-card"
|
|
69
|
+
data-clickable={clickable || undefined}
|
|
82
70
|
data-tone={tone ?? undefined}
|
|
83
|
-
style={{
|
|
84
|
-
display: "grid",
|
|
85
|
-
gap: "var(--space-2)",
|
|
86
|
-
padding: "var(--space-3)",
|
|
87
|
-
background: tone !== null ? toneSoftColors[tone] : "var(--color-neutral-0)",
|
|
88
|
-
border: "1px solid var(--color-neutral-200)",
|
|
89
|
-
borderRadius: "var(--radius-lg)",
|
|
90
|
-
boxShadow: "var(--shadow-sm)",
|
|
91
|
-
cursor: clickable ? "pointer" : undefined,
|
|
92
|
-
}}
|
|
93
71
|
>
|
|
94
72
|
{clickable && (
|
|
95
73
|
<button
|
|
@@ -102,10 +80,9 @@ export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
|
|
|
102
80
|
event.stopPropagation();
|
|
103
81
|
props.onRowClick?.(row);
|
|
104
82
|
}}
|
|
105
|
-
style={recordButtonStyle}
|
|
106
83
|
/>
|
|
107
84
|
)}
|
|
108
|
-
<div
|
|
85
|
+
<div data-terp="dataview-card-main">
|
|
109
86
|
{props.renderExpanded !== undefined && (
|
|
110
87
|
<DataViewExpandToggle
|
|
111
88
|
expanded={expanded}
|
|
@@ -113,7 +90,7 @@ export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
|
|
|
113
90
|
/>
|
|
114
91
|
)}
|
|
115
92
|
{props.selectionEnabled && (
|
|
116
|
-
<span onClick={(event) => event.stopPropagation()}
|
|
93
|
+
<span onClick={(event) => event.stopPropagation()}>
|
|
117
94
|
<input
|
|
118
95
|
type="checkbox"
|
|
119
96
|
aria-label={resolve(strings.selectRow)}
|
|
@@ -122,7 +99,7 @@ export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
|
|
|
122
99
|
/>
|
|
123
100
|
</span>
|
|
124
101
|
)}
|
|
125
|
-
<div
|
|
102
|
+
<div data-terp="dataview-card-body">
|
|
126
103
|
{props.renderCard !== undefined ? (
|
|
127
104
|
props.renderCard(row)
|
|
128
105
|
) : (
|
|
@@ -139,12 +116,7 @@ export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
|
|
|
139
116
|
)}
|
|
140
117
|
</div>
|
|
141
118
|
{expanded && props.renderExpanded !== undefined && (
|
|
142
|
-
<div
|
|
143
|
-
style={{
|
|
144
|
-
borderTop: "1px solid var(--color-neutral-200)",
|
|
145
|
-
paddingTop: "var(--space-2)",
|
|
146
|
-
}}
|
|
147
|
-
>
|
|
119
|
+
<div data-terp="dataview-card-expanded">
|
|
148
120
|
{props.renderExpanded(row)}
|
|
149
121
|
</div>
|
|
150
122
|
)}
|
|
@@ -162,33 +134,15 @@ function DefaultCardBody<T>({ row, columns }: { row: T; columns: DataViewColumn<
|
|
|
162
134
|
const status = slotValue(columns, row, "status");
|
|
163
135
|
const date = slotValue(columns, row, "date");
|
|
164
136
|
return (
|
|
165
|
-
<div
|
|
166
|
-
<div
|
|
167
|
-
<span
|
|
137
|
+
<div data-terp="dataview-card-fields">
|
|
138
|
+
<div data-terp="dataview-card-heading">
|
|
139
|
+
<span data-terp="dataview-card-title">{title}</span>
|
|
168
140
|
{status !== null && (
|
|
169
|
-
<span
|
|
170
|
-
style={{
|
|
171
|
-
fontSize: "var(--font-size-sm)",
|
|
172
|
-
padding: "0 var(--space-2)",
|
|
173
|
-
background: "var(--color-neutral-100)",
|
|
174
|
-
borderRadius: "var(--radius-full)",
|
|
175
|
-
color: "var(--color-neutral-700)",
|
|
176
|
-
}}
|
|
177
|
-
>
|
|
178
|
-
{status}
|
|
179
|
-
</span>
|
|
141
|
+
<span data-terp="dataview-card-status">{status}</span>
|
|
180
142
|
)}
|
|
181
143
|
</div>
|
|
182
|
-
{subtitle !== null &&
|
|
183
|
-
|
|
184
|
-
{subtitle}
|
|
185
|
-
</span>
|
|
186
|
-
)}
|
|
187
|
-
{date !== null && (
|
|
188
|
-
<span style={{ fontSize: "var(--font-size-sm)", color: "var(--color-neutral-500)" }}>
|
|
189
|
-
{date}
|
|
190
|
-
</span>
|
|
191
|
-
)}
|
|
144
|
+
{subtitle !== null && <span data-terp="dataview-card-meta">{subtitle}</span>}
|
|
145
|
+
{date !== null && <span data-terp="dataview-card-meta">{date}</span>}
|
|
192
146
|
</div>
|
|
193
147
|
);
|
|
194
148
|
}
|