@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
package/src/ui/Badge.tsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
import { useUiText } from "../uiText";
|
|
4
|
+
import type { UiText } from "../uiText";
|
|
5
|
+
|
|
6
|
+
export type BadgeTone = "neutral" | "info" | "success" | "warning" | "danger";
|
|
7
|
+
|
|
8
|
+
export interface BadgeProps {
|
|
9
|
+
label: UiText;
|
|
10
|
+
tone?: BadgeTone;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const toneColor: Record<BadgeTone, string> = {
|
|
14
|
+
neutral: "var(--color-neutral-600)",
|
|
15
|
+
info: "var(--color-status-info)",
|
|
16
|
+
success: "var(--color-status-success)",
|
|
17
|
+
warning: "var(--color-status-warning)",
|
|
18
|
+
danger: "var(--color-status-danger)",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const toneSoft: Record<BadgeTone, string> = {
|
|
22
|
+
neutral: "var(--color-neutral-100)",
|
|
23
|
+
info: "var(--color-status-info-soft)",
|
|
24
|
+
success: "var(--color-status-success-soft)",
|
|
25
|
+
warning: "var(--color-status-warning-soft)",
|
|
26
|
+
danger: "var(--color-status-danger-soft)",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const badgeStyle = (tone: BadgeTone): CSSProperties => ({
|
|
30
|
+
display: "inline-flex",
|
|
31
|
+
alignItems: "center",
|
|
32
|
+
border: `1px solid ${toneSoft[tone]}`,
|
|
33
|
+
borderRadius: "var(--radius-full)",
|
|
34
|
+
padding: "2px var(--space-2)",
|
|
35
|
+
color: toneColor[tone],
|
|
36
|
+
background: toneSoft[tone],
|
|
37
|
+
fontSize: "var(--font-size-xs)",
|
|
38
|
+
fontWeight: "var(--font-weight-semibold)" as never,
|
|
39
|
+
lineHeight: 1.4,
|
|
40
|
+
whiteSpace: "nowrap",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Small token-styled status pill — flat soft tint with a matching text colour. */
|
|
44
|
+
export function Badge({ label, tone = "neutral" }: BadgeProps) {
|
|
45
|
+
const resolve = useUiText();
|
|
46
|
+
return <span style={badgeStyle(tone)}>{resolve(label)}</span>;
|
|
47
|
+
}
|
|
48
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { Button } from "./Button";
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
describe("Button", () => {
|
|
10
|
+
it("renders an accessible button with a default type and token styling", () => {
|
|
11
|
+
render(<Button>Save</Button>);
|
|
12
|
+
const button = screen.getByRole("button", { name: "Save" });
|
|
13
|
+
expect(button).toHaveAttribute("type", "button");
|
|
14
|
+
expect(button).toHaveAttribute("data-terp", "button");
|
|
15
|
+
expect(button).toHaveAttribute("data-variant", "primary");
|
|
16
|
+
expect(button.style.background).toContain("var(--color-brand-primary)");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("renders the ghost variant with a transparent background", () => {
|
|
20
|
+
render(<Button variant="ghost">Cancel</Button>);
|
|
21
|
+
const button = screen.getByRole("button", { name: "Cancel" });
|
|
22
|
+
expect(button).toHaveAttribute("data-variant", "ghost");
|
|
23
|
+
expect(button.style.background).toBe("transparent");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("renders a leading icon before the children", () => {
|
|
27
|
+
render(
|
|
28
|
+
<Button icon={<span data-testid="ico">i</span>}>Do it</Button>,
|
|
29
|
+
);
|
|
30
|
+
const button = screen.getByRole("button", { name: "Do it" });
|
|
31
|
+
const icon = screen.getByTestId("ico");
|
|
32
|
+
expect(button.contains(icon)).toBe(true);
|
|
33
|
+
expect(button.textContent).toBe("iDo it");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { injectTerpStyles } from "../styles";
|
|
4
|
+
import { CONTROL_TEXT_STYLE } from "./controlStyles";
|
|
5
|
+
|
|
6
|
+
injectTerpStyles();
|
|
7
|
+
|
|
8
|
+
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
|
9
|
+
|
|
10
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
11
|
+
variant?: ButtonVariant;
|
|
12
|
+
/** Optional leading icon, rendered before `children` (e.g. `<Icon name="plus" />`). */
|
|
13
|
+
icon?: ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const baseStyle: CSSProperties = {
|
|
17
|
+
...CONTROL_TEXT_STYLE,
|
|
18
|
+
display: "inline-flex",
|
|
19
|
+
alignItems: "center",
|
|
20
|
+
justifyContent: "center",
|
|
21
|
+
gap: "var(--space-2)",
|
|
22
|
+
fontWeight: "var(--font-weight-medium)" as never,
|
|
23
|
+
lineHeight: 1.2,
|
|
24
|
+
width: "fit-content",
|
|
25
|
+
maxWidth: "100%",
|
|
26
|
+
minHeight: "2.25rem",
|
|
27
|
+
padding: "0 var(--space-4)",
|
|
28
|
+
border: "1px solid transparent",
|
|
29
|
+
borderRadius: "var(--radius-md)",
|
|
30
|
+
boxSizing: "border-box",
|
|
31
|
+
cursor: "pointer",
|
|
32
|
+
whiteSpace: "normal",
|
|
33
|
+
textAlign: "center",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const variantStyle: Record<ButtonVariant, CSSProperties> = {
|
|
37
|
+
primary: {
|
|
38
|
+
background: "var(--color-brand-primary)",
|
|
39
|
+
color: "var(--color-brand-primary-contrast)",
|
|
40
|
+
boxShadow: "var(--shadow-sm)",
|
|
41
|
+
},
|
|
42
|
+
secondary: {
|
|
43
|
+
background: "var(--color-neutral-0)",
|
|
44
|
+
color: "var(--color-neutral-900)",
|
|
45
|
+
borderColor: "var(--color-neutral-300)",
|
|
46
|
+
},
|
|
47
|
+
danger: {
|
|
48
|
+
background: "var(--color-status-danger)",
|
|
49
|
+
color: "var(--color-neutral-0)",
|
|
50
|
+
},
|
|
51
|
+
ghost: {
|
|
52
|
+
background: "transparent",
|
|
53
|
+
color: "var(--color-neutral-700)",
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const iconWrapStyle: CSSProperties = {
|
|
58
|
+
display: "inline-flex",
|
|
59
|
+
alignItems: "center",
|
|
60
|
+
justifyContent: "center",
|
|
61
|
+
flexShrink: 0,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Token-styled button — use instead of a raw `<button>` (the module-boundary rule). It
|
|
66
|
+
* styles only via the design-token CSS variables, so it themes with the app. Hover,
|
|
67
|
+
* active and `:focus-visible` states are layered on via the injected react-core sheet,
|
|
68
|
+
* keyed by the `data-terp` / `data-variant` attributes set below.
|
|
69
|
+
*/
|
|
70
|
+
export function Button({
|
|
71
|
+
variant = "primary",
|
|
72
|
+
icon,
|
|
73
|
+
style,
|
|
74
|
+
type = "button",
|
|
75
|
+
children,
|
|
76
|
+
...rest
|
|
77
|
+
}: ButtonProps) {
|
|
78
|
+
return (
|
|
79
|
+
<button
|
|
80
|
+
type={type}
|
|
81
|
+
data-terp="button"
|
|
82
|
+
data-variant={variant}
|
|
83
|
+
{...rest}
|
|
84
|
+
style={{ ...baseStyle, ...variantStyle[variant], ...style }}
|
|
85
|
+
>
|
|
86
|
+
{icon !== undefined && (
|
|
87
|
+
<span aria-hidden="true" style={iconWrapStyle}>
|
|
88
|
+
{icon}
|
|
89
|
+
</span>
|
|
90
|
+
)}
|
|
91
|
+
{children}
|
|
92
|
+
</button>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
@@ -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 { Card } from "./Card";
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
describe("Card", () => {
|
|
10
|
+
it("renders a bordered token-styled section with a semantic h3 title", () => {
|
|
11
|
+
render(
|
|
12
|
+
<Card title="Bezetting per persoon" description="Uren per maand.">
|
|
13
|
+
<p>body</p>
|
|
14
|
+
</Card>,
|
|
15
|
+
);
|
|
16
|
+
const heading = screen.getByRole("heading", { level: 3, name: "Bezetting per persoon" });
|
|
17
|
+
expect(heading).toBeInTheDocument();
|
|
18
|
+
const card = heading.closest('[data-terp="card"]') as HTMLElement;
|
|
19
|
+
expect(card.tagName).toBe("SECTION");
|
|
20
|
+
expect(card.style.border).toContain("var(--color-neutral-200)");
|
|
21
|
+
expect(card.style.background).toContain("var(--color-neutral-0)");
|
|
22
|
+
expect(screen.getByText("Uren per maand.")).toBeInTheDocument();
|
|
23
|
+
expect(screen.getByText("body")).toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("renders the actions slot in the header row", () => {
|
|
27
|
+
render(
|
|
28
|
+
<Card title="Projecten" actions={<button type="button">Nieuw</button>}>
|
|
29
|
+
inhoud
|
|
30
|
+
</Card>,
|
|
31
|
+
);
|
|
32
|
+
expect(screen.getByRole("button", { name: "Nieuw" })).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("renders headerless with children only", () => {
|
|
36
|
+
render(<Card>alleen inhoud</Card>);
|
|
37
|
+
const card = screen.getByText("alleen inhoud").closest('[data-terp="card"]');
|
|
38
|
+
expect(card?.querySelector('[data-terp="card-header"]')).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/ui/Card.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import type { SpaceToken } from "../layout";
|
|
4
|
+
import { useUiText } from "../uiText";
|
|
5
|
+
import type { UiText } from "../uiText";
|
|
6
|
+
|
|
7
|
+
export interface CardProps
|
|
8
|
+
extends Omit<HTMLAttributes<HTMLElement>, "style" | "title"> {
|
|
9
|
+
/** Optional section heading, rendered as an `<h3>` in the card's header row. */
|
|
10
|
+
title?: UiText;
|
|
11
|
+
/** Optional muted one-liner under the title (what this block is about). */
|
|
12
|
+
description?: UiText;
|
|
13
|
+
/** Optional right-hand slot in the header row (filters, a legend, an action). */
|
|
14
|
+
actions?: ReactNode;
|
|
15
|
+
/** The rendered element — `"section"` by default (a titled block of a page). */
|
|
16
|
+
as?: "section" | "article" | "div" | "aside";
|
|
17
|
+
/** Gap between body children, as a step on the token spacing scale (default `3`). */
|
|
18
|
+
gap?: SpaceToken;
|
|
19
|
+
children?: ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const cardStyle: CSSProperties = {
|
|
23
|
+
display: "flex",
|
|
24
|
+
flexDirection: "column",
|
|
25
|
+
background: "var(--color-neutral-0)",
|
|
26
|
+
border: "1px solid var(--color-neutral-200)",
|
|
27
|
+
borderRadius: "var(--radius-lg)",
|
|
28
|
+
padding: "var(--space-4)",
|
|
29
|
+
minWidth: 0,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const headerStyle: CSSProperties = {
|
|
33
|
+
display: "flex",
|
|
34
|
+
alignItems: "center",
|
|
35
|
+
justifyContent: "space-between",
|
|
36
|
+
flexWrap: "wrap",
|
|
37
|
+
gap: "var(--space-3)",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const titleStyle: CSSProperties = {
|
|
41
|
+
margin: 0,
|
|
42
|
+
fontSize: "var(--font-size-base)",
|
|
43
|
+
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
44
|
+
lineHeight: 1.3,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const descriptionStyle: CSSProperties = {
|
|
48
|
+
margin: 0,
|
|
49
|
+
color: "var(--color-neutral-600)",
|
|
50
|
+
fontSize: "var(--font-size-sm)",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A token-styled surface that groups one block of a page — the sanctioned way to give
|
|
55
|
+
* sections visual separation (border + background + padding) without module CSS. An
|
|
56
|
+
* optional header row carries a semantic `<h3>` title, a muted description and an
|
|
57
|
+
* `actions` slot; the body stacks its children on the token spacing scale.
|
|
58
|
+
*/
|
|
59
|
+
export function Card({
|
|
60
|
+
title,
|
|
61
|
+
description,
|
|
62
|
+
actions,
|
|
63
|
+
as: Component = "section",
|
|
64
|
+
gap = 3,
|
|
65
|
+
children,
|
|
66
|
+
...rest
|
|
67
|
+
}: CardProps) {
|
|
68
|
+
const resolve = useUiText();
|
|
69
|
+
const hasHeader = title !== undefined || actions !== undefined;
|
|
70
|
+
return (
|
|
71
|
+
<Component {...rest} data-terp="card" style={{ ...cardStyle, gap: `var(--space-${gap})` }}>
|
|
72
|
+
{hasHeader ? (
|
|
73
|
+
<div data-terp="card-header" style={headerStyle}>
|
|
74
|
+
<div style={{ minWidth: 0 }}>
|
|
75
|
+
{title !== undefined ? <h3 style={titleStyle}>{resolve(title)}</h3> : null}
|
|
76
|
+
{description !== undefined ? (
|
|
77
|
+
<p style={descriptionStyle}>{resolve(description)}</p>
|
|
78
|
+
) : null}
|
|
79
|
+
</div>
|
|
80
|
+
{actions !== undefined ? (
|
|
81
|
+
<div data-terp="card-actions" style={{ flexShrink: 0 }}>
|
|
82
|
+
{actions}
|
|
83
|
+
</div>
|
|
84
|
+
) : null}
|
|
85
|
+
</div>
|
|
86
|
+
) : description !== undefined ? (
|
|
87
|
+
<p style={descriptionStyle}>{resolve(description)}</p>
|
|
88
|
+
) : null}
|
|
89
|
+
{children}
|
|
90
|
+
</Component>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
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 { Checkbox } from "./Checkbox";
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
describe("Checkbox", () => {
|
|
10
|
+
it("renders a labelled checkbox and reports checked changes", () => {
|
|
11
|
+
const onChange = vi.fn();
|
|
12
|
+
render(<Checkbox label="Accept terms" checked={false} onChange={onChange} />);
|
|
13
|
+
const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
|
|
14
|
+
fireEvent.click(checkbox);
|
|
15
|
+
expect(onChange).toHaveBeenCalledWith(true);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { CSSProperties, InputHTMLAttributes } from "react";
|
|
2
|
+
|
|
3
|
+
import { injectTerpStyles } from "../styles";
|
|
4
|
+
import { useUiText } from "../uiText";
|
|
5
|
+
import type { UiText } from "../uiText";
|
|
6
|
+
|
|
7
|
+
injectTerpStyles();
|
|
8
|
+
|
|
9
|
+
const labelStyle: CSSProperties = {
|
|
10
|
+
display: "inline-flex",
|
|
11
|
+
alignItems: "center",
|
|
12
|
+
gap: "var(--space-2)",
|
|
13
|
+
color: "var(--color-neutral-900)",
|
|
14
|
+
cursor: "pointer",
|
|
15
|
+
fontSize: "var(--font-size-sm)",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const inputStyle: CSSProperties = {
|
|
19
|
+
inlineSize: "1rem",
|
|
20
|
+
blockSize: "1rem",
|
|
21
|
+
accentColor: "var(--color-brand-primary)",
|
|
22
|
+
cursor: "pointer",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface CheckboxProps
|
|
26
|
+
extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "defaultChecked" | "onChange"> {
|
|
27
|
+
label: UiText;
|
|
28
|
+
checked?: boolean;
|
|
29
|
+
defaultChecked?: boolean;
|
|
30
|
+
onChange?: (checked: boolean) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Token-styled labelled checkbox — use instead of a raw `<input type="checkbox">`. */
|
|
34
|
+
export function Checkbox({ label, checked, defaultChecked, onChange, style, ...rest }: CheckboxProps) {
|
|
35
|
+
const resolve = useUiText();
|
|
36
|
+
return (
|
|
37
|
+
<label style={{ ...labelStyle, ...style }}>
|
|
38
|
+
<input
|
|
39
|
+
{...rest}
|
|
40
|
+
type="checkbox"
|
|
41
|
+
data-terp="checkbox"
|
|
42
|
+
checked={checked}
|
|
43
|
+
defaultChecked={defaultChecked}
|
|
44
|
+
onChange={(event) => onChange?.(event.currentTarget.checked)}
|
|
45
|
+
style={inputStyle}
|
|
46
|
+
/>
|
|
47
|
+
<span>{resolve(label)}</span>
|
|
48
|
+
</label>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
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 { Field } from "../Field";
|
|
6
|
+
import { Combobox } from "./Combobox";
|
|
7
|
+
|
|
8
|
+
const options = [
|
|
9
|
+
{ value: "nl", label: "Netherlands" },
|
|
10
|
+
{ value: "be", label: "Belgium" },
|
|
11
|
+
{ value: "de", label: "Germany", disabled: true },
|
|
12
|
+
{ value: "fr", label: "France" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
afterEach(cleanup);
|
|
16
|
+
|
|
17
|
+
describe("Combobox", () => {
|
|
18
|
+
it("filters options and selects an uncontrolled value", () => {
|
|
19
|
+
const onChange = vi.fn();
|
|
20
|
+
render(<Combobox aria-label="Country" options={options} onChange={onChange} />);
|
|
21
|
+
const input = screen.getByRole("combobox", { name: /Country/ });
|
|
22
|
+
expect(input).toHaveAttribute("aria-expanded", "false");
|
|
23
|
+
fireEvent.focus(input);
|
|
24
|
+
expect(input).toHaveAttribute("aria-expanded", "true");
|
|
25
|
+
fireEvent.change(input, { target: { value: "bel" } });
|
|
26
|
+
expect(screen.getByRole("option", { name: "Belgium" })).toBeInTheDocument();
|
|
27
|
+
expect(screen.queryByRole("option", { name: "France" })).not.toBeInTheDocument();
|
|
28
|
+
fireEvent.click(screen.getByRole("option", { name: "Belgium" }));
|
|
29
|
+
expect(input).toHaveValue("Belgium");
|
|
30
|
+
expect(onChange).toHaveBeenCalledWith("be", options[1]);
|
|
31
|
+
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("supports controlled value, Field labels, ARIA active option and keyboard navigation", () => {
|
|
35
|
+
const onChange = vi.fn();
|
|
36
|
+
render(
|
|
37
|
+
<Field label="Country" error="Required">
|
|
38
|
+
<Combobox options={options} value="nl" onChange={onChange} aria-invalid />
|
|
39
|
+
</Field>,
|
|
40
|
+
);
|
|
41
|
+
const input = screen.getByRole("combobox", { name: /Country/ });
|
|
42
|
+
expect(input).toHaveValue("Netherlands");
|
|
43
|
+
expect(input).toHaveAttribute("aria-invalid", "true");
|
|
44
|
+
fireEvent.focus(input);
|
|
45
|
+
fireEvent.keyDown(input, { key: "ArrowDown" });
|
|
46
|
+
expect(input).toHaveAttribute("aria-activedescendant", expect.stringContaining("be"));
|
|
47
|
+
fireEvent.keyDown(input, { key: "End" });
|
|
48
|
+
fireEvent.keyDown(input, { key: "Enter" });
|
|
49
|
+
expect(onChange).toHaveBeenCalledWith("fr", options[3]);
|
|
50
|
+
expect(input).toHaveValue("Netherlands");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("shows loading and disabled states", () => {
|
|
54
|
+
render(<Combobox aria-label="Assignee" options={[]} loading disabled />);
|
|
55
|
+
expect(screen.getByRole("combobox", { name: "Assignee" })).toBeDisabled();
|
|
56
|
+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
});
|