@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.
Files changed (154) hide show
  1. package/README.md +190 -0
  2. package/package.json +44 -0
  3. package/src/AppShell.test.tsx +152 -0
  4. package/src/AppShell.tsx +554 -0
  5. package/src/Authorized.test.tsx +60 -0
  6. package/src/Authorized.tsx +21 -0
  7. package/src/Breadcrumbs.test.tsx +45 -0
  8. package/src/Breadcrumbs.tsx +110 -0
  9. package/src/ConfirmDialog.tsx +170 -0
  10. package/src/DetailPage.tsx +28 -0
  11. package/src/EmptyState.tsx +74 -0
  12. package/src/ErrorState.tsx +108 -0
  13. package/src/Field.test.tsx +53 -0
  14. package/src/Field.tsx +51 -0
  15. package/src/HubPage.test.tsx +108 -0
  16. package/src/HubPage.tsx +204 -0
  17. package/src/LoadingState.test.tsx +40 -0
  18. package/src/LoadingState.tsx +96 -0
  19. package/src/LoginView.test.tsx +57 -0
  20. package/src/LoginView.tsx +203 -0
  21. package/src/ModuleNav.test.tsx +96 -0
  22. package/src/ModuleNav.tsx +88 -0
  23. package/src/OverviewPage.tsx +26 -0
  24. package/src/Page.test.tsx +147 -0
  25. package/src/Page.tsx +158 -0
  26. package/src/PageActions.test.tsx +104 -0
  27. package/src/PageActions.tsx +72 -0
  28. package/src/ProfileView.test.tsx +112 -0
  29. package/src/ProfileView.tsx +84 -0
  30. package/src/RequireAuth.test.tsx +89 -0
  31. package/src/RequireAuth.tsx +22 -0
  32. package/src/ResourceList.test.tsx +176 -0
  33. package/src/ResourceList.tsx +123 -0
  34. package/src/TerpProvider.tsx +320 -0
  35. package/src/UserMenu.test.tsx +166 -0
  36. package/src/UserMenu.tsx +125 -0
  37. package/src/admin/AdminHub.tsx +108 -0
  38. package/src/admin/AuditLogAdmin.tsx +116 -0
  39. package/src/admin/GroupCreate.tsx +90 -0
  40. package/src/admin/GroupDetail.tsx +446 -0
  41. package/src/admin/GroupsAdmin.tsx +109 -0
  42. package/src/admin/UserCreate.tsx +115 -0
  43. package/src/admin/UserDetail.tsx +228 -0
  44. package/src/admin/UsersAdmin.tsx +111 -0
  45. package/src/admin/admin.test.tsx +537 -0
  46. package/src/admin/crumbs.tsx +14 -0
  47. package/src/admin/module.tsx +51 -0
  48. package/src/admin/roles.ts +19 -0
  49. package/src/bootstrap.test.tsx +67 -0
  50. package/src/bootstrap.tsx +270 -0
  51. package/src/capabilities.test.ts +24 -0
  52. package/src/capabilities.ts +26 -0
  53. package/src/createAuthClient.test.ts +176 -0
  54. package/src/createAuthClient.ts +105 -0
  55. package/src/dataview/DataView.test.tsx +392 -0
  56. package/src/dataview/DataView.tsx +467 -0
  57. package/src/dataview/DataViewCardList.tsx +189 -0
  58. package/src/dataview/DataViewColumnSettings.tsx +118 -0
  59. package/src/dataview/DataViewExpandableRow.tsx +67 -0
  60. package/src/dataview/DataViewPagination.tsx +113 -0
  61. package/src/dataview/DataViewRowActions.tsx +131 -0
  62. package/src/dataview/DataViewTable.tsx +359 -0
  63. package/src/dataview/DataViewToolbar.tsx +260 -0
  64. package/src/dataview/README.md +138 -0
  65. package/src/dataview/glyphs.tsx +175 -0
  66. package/src/dataview/hooks/hooks.test.tsx +240 -0
  67. package/src/dataview/hooks/useDataViewQuery.ts +72 -0
  68. package/src/dataview/hooks/useDataViewState.ts +310 -0
  69. package/src/dataview/hooks/useServerDataView.ts +154 -0
  70. package/src/dataview/hooks/useViewSearch.ts +68 -0
  71. package/src/dataview/index.ts +62 -0
  72. package/src/dataview/internal.tsx +96 -0
  73. package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
  74. package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
  75. package/src/dataview/repositories/repositories.test.ts +158 -0
  76. package/src/dataview/repositories/viewState.test.ts +90 -0
  77. package/src/dataview/repositories/viewState.ts +128 -0
  78. package/src/dataview/types.ts +249 -0
  79. package/src/errorMessages.test.tsx +83 -0
  80. package/src/errorMessages.tsx +79 -0
  81. package/src/feedback.test.tsx +167 -0
  82. package/src/files.test.tsx +142 -0
  83. package/src/files.tsx +174 -0
  84. package/src/icons.test.tsx +46 -0
  85. package/src/icons.tsx +533 -0
  86. package/src/index.ts +155 -0
  87. package/src/layout.test.tsx +72 -0
  88. package/src/layout.tsx +90 -0
  89. package/src/layoutContract.test.tsx +179 -0
  90. package/src/layoutContract.ts +137 -0
  91. package/src/locale.test.tsx +97 -0
  92. package/src/locale.tsx +246 -0
  93. package/src/nav.test.ts +21 -0
  94. package/src/nav.ts +13 -0
  95. package/src/pageMarker.ts +15 -0
  96. package/src/raw.d.ts +7 -0
  97. package/src/realtime-hook.test.tsx +226 -0
  98. package/src/realtime.test.ts +44 -0
  99. package/src/realtime.ts +307 -0
  100. package/src/refresh-session.test.tsx +114 -0
  101. package/src/revocation.test.tsx +81 -0
  102. package/src/router.test.tsx +307 -0
  103. package/src/router.tsx +222 -0
  104. package/src/sso.test.tsx +128 -0
  105. package/src/sso.ts +142 -0
  106. package/src/ssr.test.tsx +45 -0
  107. package/src/styles.test.ts +21 -0
  108. package/src/styles.ts +302 -0
  109. package/src/theme.test.tsx +74 -0
  110. package/src/theme.tsx +143 -0
  111. package/src/toast.test.tsx +94 -0
  112. package/src/toast.tsx +214 -0
  113. package/src/tokens.guard.test.ts +51 -0
  114. package/src/ui/Alert.test.tsx +19 -0
  115. package/src/ui/Alert.tsx +115 -0
  116. package/src/ui/Badge.test.tsx +14 -0
  117. package/src/ui/Badge.tsx +48 -0
  118. package/src/ui/Button.test.tsx +36 -0
  119. package/src/ui/Button.tsx +95 -0
  120. package/src/ui/Card.test.tsx +40 -0
  121. package/src/ui/Card.tsx +92 -0
  122. package/src/ui/Checkbox.test.tsx +17 -0
  123. package/src/ui/Checkbox.tsx +51 -0
  124. package/src/ui/Combobox.test.tsx +58 -0
  125. package/src/ui/Combobox.tsx +313 -0
  126. package/src/ui/DatePicker.test.tsx +60 -0
  127. package/src/ui/DatePicker.tsx +421 -0
  128. package/src/ui/Input.tsx +30 -0
  129. package/src/ui/Markdown.test.tsx +32 -0
  130. package/src/ui/Markdown.tsx +213 -0
  131. package/src/ui/Menu.test.tsx +85 -0
  132. package/src/ui/Menu.tsx +216 -0
  133. package/src/ui/Popover.tsx +218 -0
  134. package/src/ui/Radio.test.tsx +29 -0
  135. package/src/ui/Radio.tsx +127 -0
  136. package/src/ui/Select.tsx +40 -0
  137. package/src/ui/Switch.test.tsx +17 -0
  138. package/src/ui/Switch.tsx +53 -0
  139. package/src/ui/Tabs.test.tsx +29 -0
  140. package/src/ui/Tabs.tsx +128 -0
  141. package/src/ui/Textarea.tsx +27 -0
  142. package/src/ui/Tooltip.test.tsx +28 -0
  143. package/src/ui/Tooltip.tsx +67 -0
  144. package/src/ui/controlStyles.ts +9 -0
  145. package/src/uiText.test.tsx +93 -0
  146. package/src/uiText.tsx +342 -0
  147. package/src/unwrap.test.ts +67 -0
  148. package/src/unwrap.ts +101 -0
  149. package/src/useResource.test.tsx +118 -0
  150. package/src/useResource.ts +110 -0
  151. package/src/useTerpClient.test.ts +35 -0
  152. package/tsconfig.json +17 -0
  153. package/vite.config.ts +14 -0
  154. package/vitest.setup.ts +58 -0
@@ -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
+ });
@@ -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
+ });