@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,40 @@
|
|
|
1
|
+
import type { CSSProperties, SelectHTMLAttributes } from "react";
|
|
2
|
+
|
|
3
|
+
import { injectTerpStyles } from "../styles";
|
|
4
|
+
import { CONTROL_TEXT_STYLE } from "./controlStyles";
|
|
5
|
+
|
|
6
|
+
injectTerpStyles();
|
|
7
|
+
|
|
8
|
+
// SVG chevron rendered via a data URL background — keeps the token-only styling
|
|
9
|
+
// contract while giving <select> a consistent affordance across platforms.
|
|
10
|
+
const chevronUrl =
|
|
11
|
+
"url(\"data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 8 5 5 5-5'/%3E%3C/svg%3E\")";
|
|
12
|
+
|
|
13
|
+
const selectStyle: CSSProperties = {
|
|
14
|
+
...CONTROL_TEXT_STYLE,
|
|
15
|
+
lineHeight: 1.2,
|
|
16
|
+
maxWidth: "100%",
|
|
17
|
+
minWidth: 0,
|
|
18
|
+
minHeight: "2.25rem",
|
|
19
|
+
padding: "0 calc(var(--space-3) + 1.25rem) 0 var(--space-3)",
|
|
20
|
+
border: "1px solid var(--color-neutral-300)",
|
|
21
|
+
borderRadius: "var(--radius-md)",
|
|
22
|
+
color: "var(--color-neutral-900)",
|
|
23
|
+
background: `${chevronUrl} no-repeat right var(--space-2) center / 1rem 1rem, var(--color-neutral-0)`,
|
|
24
|
+
boxSizing: "border-box",
|
|
25
|
+
appearance: "none",
|
|
26
|
+
WebkitAppearance: "none",
|
|
27
|
+
MozAppearance: "none",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Token-styled select — use instead of a raw `<select>` (the module-boundary rule). Pass `<option>`
|
|
34
|
+
* children as usual. The `data-terp="input"` marker opts the element into the shared focus
|
|
35
|
+
* ring; a subtle SVG chevron replaces the native affordance for a consistent look.
|
|
36
|
+
*/
|
|
37
|
+
export function Select({ style, ...rest }: SelectProps) {
|
|
38
|
+
return <select data-terp="input" {...rest} style={{ ...selectStyle, ...style }} />;
|
|
39
|
+
}
|
|
40
|
+
|
|
@@ -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 { Switch } from "./Switch";
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
describe("Switch", () => {
|
|
10
|
+
it("renders an accessible switch and reports checked changes", () => {
|
|
11
|
+
const onChange = vi.fn();
|
|
12
|
+
render(<Switch label="Email alerts" checked={false} onChange={onChange} />);
|
|
13
|
+
const toggle = screen.getByRole("switch", { name: "Email alerts" });
|
|
14
|
+
fireEvent.click(toggle);
|
|
15
|
+
expect(onChange).toHaveBeenCalledWith(true);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
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: "2.25rem",
|
|
20
|
+
blockSize: "1.25rem",
|
|
21
|
+
accentColor: "var(--color-brand-primary)",
|
|
22
|
+
cursor: "pointer",
|
|
23
|
+
transition: "background-color 150ms ease",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface SwitchProps
|
|
27
|
+
extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "defaultChecked" | "onChange" | "role"> {
|
|
28
|
+
label: UiText;
|
|
29
|
+
checked?: boolean;
|
|
30
|
+
defaultChecked?: boolean;
|
|
31
|
+
onChange?: (checked: boolean) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Token-styled labelled switch for boolean settings. */
|
|
35
|
+
export function Switch({ label, checked, defaultChecked, onChange, style, ...rest }: SwitchProps) {
|
|
36
|
+
const resolve = useUiText();
|
|
37
|
+
return (
|
|
38
|
+
<label style={{ ...labelStyle, ...style }}>
|
|
39
|
+
<input
|
|
40
|
+
{...rest}
|
|
41
|
+
type="checkbox"
|
|
42
|
+
role="switch"
|
|
43
|
+
data-terp="switch"
|
|
44
|
+
checked={checked}
|
|
45
|
+
defaultChecked={defaultChecked}
|
|
46
|
+
onChange={(event) => onChange?.(event.currentTarget.checked)}
|
|
47
|
+
style={inputStyle}
|
|
48
|
+
/>
|
|
49
|
+
<span>{resolve(label)}</span>
|
|
50
|
+
</label>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
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 { Tabs } from "./Tabs";
|
|
6
|
+
|
|
7
|
+
const tabs = [
|
|
8
|
+
{ value: "overview", label: "Overview", content: <p>Overview panel</p> },
|
|
9
|
+
{ value: "audit", label: "Audit", content: <p>Audit panel</p> },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
afterEach(cleanup);
|
|
13
|
+
|
|
14
|
+
describe("Tabs", () => {
|
|
15
|
+
it("renders tabs and switches uncontrolled selection by click", () => {
|
|
16
|
+
render(<Tabs label="Sections" tabs={tabs} defaultValue="overview" />);
|
|
17
|
+
expect(screen.getByRole("tablist", { name: "Sections" })).toBeInTheDocument();
|
|
18
|
+
expect(screen.getByRole("tabpanel")).toHaveTextContent("Overview panel");
|
|
19
|
+
fireEvent.click(screen.getByRole("tab", { name: "Audit" }));
|
|
20
|
+
expect(screen.getByRole("tabpanel")).toHaveTextContent("Audit panel");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("reports controlled changes and supports arrow navigation", () => {
|
|
24
|
+
const onChange = vi.fn();
|
|
25
|
+
render(<Tabs label="Sections" tabs={tabs} value="overview" onChange={onChange} />);
|
|
26
|
+
fireEvent.keyDown(screen.getByRole("tablist"), { key: "ArrowRight" });
|
|
27
|
+
expect(onChange).toHaveBeenCalledWith("audit");
|
|
28
|
+
});
|
|
29
|
+
});
|
package/src/ui/Tabs.tsx
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useId, useMemo, useState } from "react";
|
|
2
|
+
import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { injectTerpStyles } from "../styles";
|
|
5
|
+
import { useUiText } from "../uiText";
|
|
6
|
+
import type { UiText } from "../uiText";
|
|
7
|
+
import { CONTROL_TEXT_STYLE } from "./controlStyles";
|
|
8
|
+
|
|
9
|
+
injectTerpStyles();
|
|
10
|
+
|
|
11
|
+
const rootStyle: CSSProperties = { display: "grid", gap: "var(--space-3)" };
|
|
12
|
+
const tabListStyle: CSSProperties = {
|
|
13
|
+
display: "flex",
|
|
14
|
+
flexWrap: "wrap",
|
|
15
|
+
gap: "var(--space-1)",
|
|
16
|
+
borderBlockEnd: "1px solid var(--color-neutral-200)",
|
|
17
|
+
};
|
|
18
|
+
const tabStyle = (selected: boolean): CSSProperties => ({
|
|
19
|
+
...CONTROL_TEXT_STYLE,
|
|
20
|
+
fontWeight: (selected
|
|
21
|
+
? "var(--font-weight-semibold)"
|
|
22
|
+
: "var(--font-weight-medium)") as never,
|
|
23
|
+
padding: "var(--space-2) var(--space-3)",
|
|
24
|
+
border: 0,
|
|
25
|
+
borderBlockEnd: selected ? "2px solid var(--color-brand-primary)" : "2px solid transparent",
|
|
26
|
+
color: selected ? "var(--color-brand-primary)" : "var(--color-neutral-600)",
|
|
27
|
+
background: "transparent",
|
|
28
|
+
cursor: "pointer",
|
|
29
|
+
marginBlockEnd: "-1px",
|
|
30
|
+
});
|
|
31
|
+
const panelStyle: CSSProperties = { color: "var(--color-neutral-900)" };
|
|
32
|
+
|
|
33
|
+
export interface TabItem {
|
|
34
|
+
value: string;
|
|
35
|
+
label: UiText;
|
|
36
|
+
content: ReactNode;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TabsProps {
|
|
41
|
+
tabs: readonly TabItem[];
|
|
42
|
+
value?: string;
|
|
43
|
+
defaultValue?: string;
|
|
44
|
+
onChange?: (value: string) => void;
|
|
45
|
+
label?: UiText;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** In-page tab set with controlled/uncontrolled selection and arrow-key navigation. */
|
|
49
|
+
export function Tabs({ tabs, value, defaultValue, onChange, label }: TabsProps) {
|
|
50
|
+
const baseId = useId();
|
|
51
|
+
const resolve = useUiText();
|
|
52
|
+
const enabledTabs = useMemo(() => tabs.filter((tab) => !tab.disabled), [tabs]);
|
|
53
|
+
const firstValue = enabledTabs[0]?.value ?? tabs[0]?.value ?? "";
|
|
54
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? firstValue);
|
|
55
|
+
const selectedValue = value ?? uncontrolledValue;
|
|
56
|
+
const selectedTab = tabs.find((tab) => tab.value === selectedValue) ?? enabledTabs[0] ?? tabs[0];
|
|
57
|
+
|
|
58
|
+
function select(next: string) {
|
|
59
|
+
if (value === undefined) {
|
|
60
|
+
setUncontrolledValue(next);
|
|
61
|
+
}
|
|
62
|
+
onChange?.(next);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
|
66
|
+
if (!["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp", "Home", "End"].includes(event.key)) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
if (enabledTabs.length === 0) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const currentIndex = Math.max(
|
|
74
|
+
0,
|
|
75
|
+
enabledTabs.findIndex((tab) => tab.value === selectedTab?.value),
|
|
76
|
+
);
|
|
77
|
+
const nextIndex =
|
|
78
|
+
event.key === "Home"
|
|
79
|
+
? 0
|
|
80
|
+
: event.key === "End"
|
|
81
|
+
? enabledTabs.length - 1
|
|
82
|
+
: event.key === "ArrowRight" || event.key === "ArrowDown"
|
|
83
|
+
? (currentIndex + 1) % enabledTabs.length
|
|
84
|
+
: (currentIndex - 1 + enabledTabs.length) % enabledTabs.length;
|
|
85
|
+
const next = enabledTabs[nextIndex];
|
|
86
|
+
if (next) {
|
|
87
|
+
select(next.value);
|
|
88
|
+
document.getElementById(`${baseId}-tab-${next.value}`)?.focus();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div data-terp="tabs" style={rootStyle}>
|
|
94
|
+
<div role="tablist" aria-label={label === undefined ? undefined : resolve(label)} style={tabListStyle} onKeyDown={onKeyDown}>
|
|
95
|
+
{tabs.map((tab) => {
|
|
96
|
+
const selected = tab.value === selectedTab?.value;
|
|
97
|
+
return (
|
|
98
|
+
<button
|
|
99
|
+
key={tab.value}
|
|
100
|
+
id={`${baseId}-tab-${tab.value}`}
|
|
101
|
+
type="button"
|
|
102
|
+
role="tab"
|
|
103
|
+
data-terp="tab"
|
|
104
|
+
aria-selected={selected}
|
|
105
|
+
aria-controls={`${baseId}-panel-${tab.value}`}
|
|
106
|
+
tabIndex={selected ? 0 : -1}
|
|
107
|
+
disabled={tab.disabled}
|
|
108
|
+
onClick={() => select(tab.value)}
|
|
109
|
+
style={tabStyle(selected)}
|
|
110
|
+
>
|
|
111
|
+
{resolve(tab.label)}
|
|
112
|
+
</button>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
115
|
+
</div>
|
|
116
|
+
{selectedTab && (
|
|
117
|
+
<div
|
|
118
|
+
id={`${baseId}-panel-${selectedTab.value}`}
|
|
119
|
+
role="tabpanel"
|
|
120
|
+
aria-labelledby={`${baseId}-tab-${selectedTab.value}`}
|
|
121
|
+
style={panelStyle}
|
|
122
|
+
>
|
|
123
|
+
{selectedTab.content}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CSSProperties, TextareaHTMLAttributes } from "react";
|
|
2
|
+
|
|
3
|
+
import { injectTerpStyles } from "../styles";
|
|
4
|
+
import { CONTROL_TEXT_STYLE } from "./controlStyles";
|
|
5
|
+
|
|
6
|
+
injectTerpStyles();
|
|
7
|
+
|
|
8
|
+
const textareaStyle: CSSProperties = {
|
|
9
|
+
...CONTROL_TEXT_STYLE,
|
|
10
|
+
lineHeight: 1.4,
|
|
11
|
+
padding: "var(--space-2) var(--space-3)",
|
|
12
|
+
border: "1px solid var(--color-neutral-300)",
|
|
13
|
+
borderRadius: "var(--radius-md)",
|
|
14
|
+
color: "var(--color-neutral-900)",
|
|
15
|
+
background: "var(--color-neutral-0)",
|
|
16
|
+
boxSizing: "border-box",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Token-styled multiline input — use instead of a raw `<textarea>` (the module-boundary rule).
|
|
23
|
+
*/
|
|
24
|
+
export function Textarea({ style, ...rest }: TextareaProps) {
|
|
25
|
+
return <textarea data-terp="input" {...rest} style={{ ...textareaStyle, ...style }} />;
|
|
26
|
+
}
|
|
27
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { Button } from "./Button";
|
|
6
|
+
import { Tooltip } from "./Tooltip";
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
|
|
10
|
+
describe("Tooltip", () => {
|
|
11
|
+
it("describes its trigger and opens on focus and hover", () => {
|
|
12
|
+
render(
|
|
13
|
+
<Tooltip content="More information">
|
|
14
|
+
<Button>Help</Button>
|
|
15
|
+
</Tooltip>,
|
|
16
|
+
);
|
|
17
|
+
const trigger = screen.getByRole("button", { name: "Help" });
|
|
18
|
+
const tooltip = screen.getByRole("tooltip", { hidden: true });
|
|
19
|
+
expect(trigger).toHaveAttribute("aria-describedby", tooltip.id);
|
|
20
|
+
expect(tooltip).not.toBeVisible();
|
|
21
|
+
fireEvent.focus(trigger);
|
|
22
|
+
expect(tooltip).toBeVisible();
|
|
23
|
+
fireEvent.blur(trigger);
|
|
24
|
+
expect(tooltip).not.toBeVisible();
|
|
25
|
+
fireEvent.mouseEnter(trigger.parentElement!);
|
|
26
|
+
expect(tooltip).toBeVisible();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { cloneElement, isValidElement, useId, useState } from "react";
|
|
2
|
+
import type { CSSProperties, FocusEvent, MouseEvent, ReactElement } from "react";
|
|
3
|
+
|
|
4
|
+
import { useUiText } from "../uiText";
|
|
5
|
+
import type { UiText } from "../uiText";
|
|
6
|
+
|
|
7
|
+
const wrapperStyle: CSSProperties = { position: "relative", display: "inline-flex" };
|
|
8
|
+
const tooltipStyle: CSSProperties = {
|
|
9
|
+
position: "absolute",
|
|
10
|
+
zIndex: 1,
|
|
11
|
+
insetBlockEnd: "calc(100% + var(--space-1))",
|
|
12
|
+
insetInlineStart: 0,
|
|
13
|
+
maxInlineSize: "min(18rem, calc(100vw - 2 * var(--space-4)))",
|
|
14
|
+
padding: "var(--space-1) var(--space-2)",
|
|
15
|
+
borderRadius: "var(--radius-sm)",
|
|
16
|
+
color: "var(--color-neutral-0)",
|
|
17
|
+
background: "var(--color-neutral-900)",
|
|
18
|
+
fontSize: "var(--font-size-xs)",
|
|
19
|
+
fontWeight: "var(--font-weight-medium)" as never,
|
|
20
|
+
lineHeight: 1.4,
|
|
21
|
+
boxShadow: "var(--shadow-md)",
|
|
22
|
+
pointerEvents: "none",
|
|
23
|
+
whiteSpace: "normal",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface TooltipProps {
|
|
27
|
+
content: UiText;
|
|
28
|
+
children: ReactElement;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface TriggerHandlers {
|
|
32
|
+
onFocus?: (event: FocusEvent) => void;
|
|
33
|
+
onBlur?: (event: FocusEvent) => void;
|
|
34
|
+
onMouseEnter?: (event: MouseEvent) => void;
|
|
35
|
+
onMouseLeave?: (event: MouseEvent) => void;
|
|
36
|
+
"aria-describedby"?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Accessible focus/hover tooltip. */
|
|
40
|
+
export function Tooltip({ content, children }: TooltipProps) {
|
|
41
|
+
const id = useId();
|
|
42
|
+
const resolve = useUiText();
|
|
43
|
+
const [open, setOpen] = useState(false);
|
|
44
|
+
|
|
45
|
+
if (!isValidElement<TriggerHandlers>(children)) {
|
|
46
|
+
return children;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<span style={wrapperStyle} onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}>
|
|
51
|
+
{cloneElement(children, {
|
|
52
|
+
"aria-describedby": id,
|
|
53
|
+
onFocus: (event: FocusEvent) => {
|
|
54
|
+
children.props.onFocus?.(event);
|
|
55
|
+
setOpen(true);
|
|
56
|
+
},
|
|
57
|
+
onBlur: (event: FocusEvent) => {
|
|
58
|
+
children.props.onBlur?.(event);
|
|
59
|
+
setOpen(false);
|
|
60
|
+
},
|
|
61
|
+
})}
|
|
62
|
+
<span id={id} role="tooltip" hidden={!open} style={tooltipStyle}>
|
|
63
|
+
{resolve(content)}
|
|
64
|
+
</span>
|
|
65
|
+
</span>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
/** Stable typography for interactive controls, independent of surrounding display text. */
|
|
4
|
+
export const CONTROL_TEXT_STYLE: CSSProperties = {
|
|
5
|
+
fontFamily: "var(--font-family-sans)",
|
|
6
|
+
fontSize: "var(--font-size-sm)",
|
|
7
|
+
fontWeight: "var(--font-weight-normal)" as CSSProperties["fontWeight"],
|
|
8
|
+
lineHeight: 1.25,
|
|
9
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { Page } from "./Page";
|
|
6
|
+
import { ResourceList } from "./ResourceList";
|
|
7
|
+
import { TerpProvider } from "./TerpProvider";
|
|
8
|
+
import { resolveUiText, UiTextProvider } from "./uiText";
|
|
9
|
+
|
|
10
|
+
afterEach(cleanup);
|
|
11
|
+
|
|
12
|
+
describe("resolveUiText", () => {
|
|
13
|
+
it("passes plain strings through and falls back to a descriptor's message", () => {
|
|
14
|
+
expect(resolveUiText("Tasks")).toBe("Tasks");
|
|
15
|
+
expect(resolveUiText({ id: "tasks.title", message: "Tasks" })).toBe("Tasks");
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("UiTextProvider", () => {
|
|
20
|
+
it("components use the bundled defaults without a provider", () => {
|
|
21
|
+
render(
|
|
22
|
+
<Page title="Tasks" isLoading>
|
|
23
|
+
x
|
|
24
|
+
</Page>,
|
|
25
|
+
);
|
|
26
|
+
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("overrides framework strings per key", () => {
|
|
30
|
+
render(
|
|
31
|
+
<UiTextProvider strings={{ loading: "Laden..." }}>
|
|
32
|
+
<Page title="Taken" isLoading>
|
|
33
|
+
x
|
|
34
|
+
</Page>
|
|
35
|
+
</UiTextProvider>,
|
|
36
|
+
);
|
|
37
|
+
expect(screen.getByText("Laden...")).toBeInTheDocument();
|
|
38
|
+
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("routes UiText props through a custom resolver (the i18n runtime hook)", () => {
|
|
42
|
+
render(
|
|
43
|
+
<UiTextProvider
|
|
44
|
+
resolveText={(text) =>
|
|
45
|
+
typeof text === "string" ? text : `[${text.id}] ${text.message}`
|
|
46
|
+
}
|
|
47
|
+
>
|
|
48
|
+
<Page title={{ id: "tasks.title", message: "Tasks" }}>x</Page>
|
|
49
|
+
</UiTextProvider>,
|
|
50
|
+
);
|
|
51
|
+
expect(
|
|
52
|
+
screen.getByRole("heading", { level: 1, name: "[tasks.title] Tasks" }),
|
|
53
|
+
).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("nested providers merge string overrides", () => {
|
|
57
|
+
render(
|
|
58
|
+
<UiTextProvider strings={{ loading: "Laden..." }}>
|
|
59
|
+
<UiTextProvider strings={{ emptyList: "Nog niets." }}>
|
|
60
|
+
<Page title="Taken" isLoading>
|
|
61
|
+
x
|
|
62
|
+
</Page>
|
|
63
|
+
</UiTextProvider>
|
|
64
|
+
</UiTextProvider>,
|
|
65
|
+
);
|
|
66
|
+
expect(screen.getByText("Laden...")).toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("localises ResourceList's empty message and create button", () => {
|
|
70
|
+
render(
|
|
71
|
+
<TerpProvider baseUrl="http://api.test">
|
|
72
|
+
<UiTextProvider strings={{ emptyList: "Nog niets.", add: "Toevoegen" }}>
|
|
73
|
+
<ResourceList
|
|
74
|
+
resource={{
|
|
75
|
+
items: [],
|
|
76
|
+
loading: false,
|
|
77
|
+
error: null,
|
|
78
|
+
cause: null,
|
|
79
|
+
create: async () => {},
|
|
80
|
+
reload: async () => {},
|
|
81
|
+
mutate: async (run: () => Promise<unknown>) => {
|
|
82
|
+
await run();
|
|
83
|
+
},
|
|
84
|
+
}}
|
|
85
|
+
renderItem={() => null}
|
|
86
|
+
createPlaceholder="Titel"
|
|
87
|
+
/>
|
|
88
|
+
</UiTextProvider>
|
|
89
|
+
</TerpProvider>,
|
|
90
|
+
);
|
|
91
|
+
expect(screen.getByText("Nog niets.")).toBeInTheDocument();
|
|
92
|
+
});
|
|
93
|
+
});
|