@terpjs/react-core 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +12 -2
  2. package/package.json +2 -2
  3. package/src/AppShell.test.tsx +33 -12
  4. package/src/AppShell.tsx +69 -249
  5. package/src/Breadcrumbs.test.tsx +24 -0
  6. package/src/Breadcrumbs.tsx +9 -32
  7. package/src/ConfirmDialog.tsx +13 -44
  8. package/src/EmptyState.tsx +8 -36
  9. package/src/ErrorState.tsx +8 -36
  10. package/src/Field.test.tsx +57 -0
  11. package/src/Field.tsx +46 -22
  12. package/src/HubPage.test.tsx +22 -13
  13. package/src/HubPage.tsx +25 -97
  14. package/src/LoadingState.tsx +3 -24
  15. package/src/ModuleNav.tsx +1 -1
  16. package/src/PageActions.tsx +5 -10
  17. package/src/UserMenu.test.tsx +12 -5
  18. package/src/UserMenu.tsx +33 -62
  19. package/src/dataview/DataView.test.tsx +109 -5
  20. package/src/dataview/DataView.tsx +41 -23
  21. package/src/dataview/DataViewCardList.tsx +14 -60
  22. package/src/dataview/DataViewColumnSettings.tsx +46 -51
  23. package/src/dataview/DataViewExpandableRow.tsx +2 -17
  24. package/src/dataview/DataViewPagination.tsx +2 -32
  25. package/src/dataview/DataViewRowActions.tsx +13 -33
  26. package/src/dataview/DataViewTable.tsx +16 -103
  27. package/src/dataview/DataViewToolbar.tsx +53 -76
  28. package/src/dataview/README.md +6 -0
  29. package/src/dataview/index.ts +1 -0
  30. package/src/dataview/internal.tsx +4 -1
  31. package/src/dataview/types.ts +13 -0
  32. package/src/feedback.test.tsx +26 -0
  33. package/src/files.test.tsx +18 -0
  34. package/src/files.tsx +13 -4
  35. package/src/icons.test.tsx +10 -6
  36. package/src/icons.tsx +33 -37
  37. package/src/index.ts +0 -3
  38. package/src/layout.test.tsx +24 -9
  39. package/src/layout.tsx +24 -21
  40. package/src/layoutContract.test.tsx +95 -0
  41. package/src/locale.tsx +27 -4
  42. package/src/markers.test.ts +468 -0
  43. package/src/raw.d.ts +15 -1
  44. package/src/router.tsx +6 -9
  45. package/src/ssr.test.tsx +1 -3
  46. package/src/styles.test.ts +823 -6
  47. package/src/styles.ts +2699 -153
  48. package/src/theme.test.tsx +39 -0
  49. package/src/theme.themes.test.ts +124 -0
  50. package/src/theme.tsx +62 -14
  51. package/src/toast.tsx +35 -71
  52. package/src/tokens.guard.test.ts +3 -12
  53. package/src/ui/Alert.test.tsx +12 -0
  54. package/src/ui/Alert.tsx +15 -43
  55. package/src/ui/Badge.test.tsx +14 -3
  56. package/src/ui/Badge.tsx +13 -25
  57. package/src/ui/Button.test.tsx +17 -4
  58. package/src/ui/Button.tsx +10 -63
  59. package/src/ui/Card.test.tsx +6 -2
  60. package/src/ui/Card.tsx +11 -39
  61. package/src/ui/Checkbox.tsx +2 -19
  62. package/src/ui/Combobox.test.tsx +22 -0
  63. package/src/ui/Combobox.tsx +31 -80
  64. package/src/ui/DatePicker.test.tsx +131 -4
  65. package/src/ui/DatePicker.tsx +158 -106
  66. package/src/ui/Input.tsx +6 -19
  67. package/src/ui/Markdown.test.tsx +26 -0
  68. package/src/ui/Markdown.tsx +28 -2
  69. package/src/ui/Menu.test.tsx +38 -4
  70. package/src/ui/Menu.tsx +50 -52
  71. package/src/ui/Popover.tsx +53 -19
  72. package/src/ui/Radio.tsx +5 -30
  73. package/src/ui/Select.tsx +7 -30
  74. package/src/ui/Switch.tsx +2 -20
  75. package/src/ui/Tabs.tsx +4 -28
  76. package/src/ui/Textarea.tsx +6 -17
  77. package/src/ui/Tooltip.tsx +9 -21
  78. package/src/uiText.tsx +9 -0
  79. package/src/ui/controlStyles.ts +0 -9
@@ -7,20 +7,25 @@ import { Button } from "./Button";
7
7
  afterEach(cleanup);
8
8
 
9
9
  describe("Button", () => {
10
- it("renders an accessible button with a default type and token styling", () => {
10
+ // These assert the attributes rather than `style.background` (ADR 0094). The variant is
11
+ // the semantic claim — that the sheet paints it is the sheet's business, and the visual
12
+ // baselines are what prove the paint. Asserting the absence of an inline style is the
13
+ // other half: it is what makes the rules in the sheet reachable at all, so a component
14
+ // that quietly regrew a base `style={}` would silently take back its own restyleability.
15
+ it("renders an accessible button with a default type and no inline styling", () => {
11
16
  render(<Button>Save</Button>);
12
17
  const button = screen.getByRole("button", { name: "Save" });
13
18
  expect(button).toHaveAttribute("type", "button");
14
19
  expect(button).toHaveAttribute("data-terp", "button");
15
20
  expect(button).toHaveAttribute("data-variant", "primary");
16
- expect(button.style.background).toContain("var(--color-brand-primary)");
21
+ expect(button.getAttribute("style")).toBeNull();
17
22
  });
18
23
 
19
- it("renders the ghost variant with a transparent background", () => {
24
+ it("names the ghost variant on the element", () => {
20
25
  render(<Button variant="ghost">Cancel</Button>);
21
26
  const button = screen.getByRole("button", { name: "Cancel" });
22
27
  expect(button).toHaveAttribute("data-variant", "ghost");
23
- expect(button.style.background).toBe("transparent");
28
+ expect(button.getAttribute("style")).toBeNull();
24
29
  });
25
30
 
26
31
  it("renders a leading icon before the children", () => {
@@ -32,5 +37,13 @@ describe("Button", () => {
32
37
  expect(button.contains(icon)).toBe(true);
33
38
  expect(button.textContent).toBe("iDo it");
34
39
  });
40
+
41
+ it("still forwards an explicit style, so framework callers keep their escape", () => {
42
+ // The sheet owns the base; a one-off geometry override (LoginView's full-width submit)
43
+ // is inline and therefore still wins, which is the boundary ADR 0094 draws between
44
+ // styling policy and a measured value.
45
+ render(<Button style={{ width: "100%" }}>Wide</Button>);
46
+ expect(screen.getByRole("button", { name: "Wide" }).style.width).toBe("100%");
47
+ });
35
48
  });
36
49
 
package/src/ui/Button.tsx CHANGED
@@ -1,7 +1,6 @@
1
- import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from "react";
1
+ import type { ButtonHTMLAttributes, ReactNode } from "react";
2
2
 
3
3
  import { injectTerpStyles } from "../styles";
4
- import { CONTROL_TEXT_STYLE } from "./controlStyles";
5
4
 
6
5
  injectTerpStyles();
7
6
 
@@ -13,78 +12,26 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
13
12
  icon?: ReactNode;
14
13
  }
15
14
 
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
15
  /**
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.
16
+ * Token-styled button — use instead of a raw `<button>` (the module-boundary rule).
17
+ *
18
+ * It renders no inline styles: the geometry, the per-variant colours and the hover /
19
+ * active / disabled / `:focus-visible` states all live in the injected react-core sheet,
20
+ * matched on the `data-terp` / `data-variant` attributes set below (ADR 0094). So the
21
+ * variant is a fact about the element rather than a style object chosen in here — which
22
+ * is what a test should assert, and what an app's `theme.css` can restyle.
69
23
  */
70
24
  export function Button({
71
25
  variant = "primary",
72
26
  icon,
73
- style,
74
27
  type = "button",
75
28
  children,
76
29
  ...rest
77
30
  }: ButtonProps) {
78
31
  return (
79
- <button
80
- type={type}
81
- data-terp="button"
82
- data-variant={variant}
83
- {...rest}
84
- style={{ ...baseStyle, ...variantStyle[variant], ...style }}
85
- >
32
+ <button type={type} data-terp="button" data-variant={variant} {...rest}>
86
33
  {icon !== undefined && (
87
- <span aria-hidden="true" style={iconWrapStyle}>
34
+ <span aria-hidden="true" data-terp="button-icon">
88
35
  {icon}
89
36
  </span>
90
37
  )}
@@ -17,8 +17,12 @@ describe("Card", () => {
17
17
  expect(heading).toBeInTheDocument();
18
18
  const card = heading.closest('[data-terp="card"]') as HTMLElement;
19
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)");
20
+ // The surface is a sheet rule (ADR 0094); the card's own claims are its element, its
21
+ // gap step and that the title is a real h3 inside the header row.
22
+ expect(card.getAttribute("style")).toBeNull();
23
+ expect(card).toHaveAttribute("data-gap", "3");
24
+ expect(heading).toHaveAttribute("data-terp", "card-title");
25
+ expect(heading.closest('[data-terp="card-header"]')).not.toBeNull();
22
26
  expect(screen.getByText("Uren per maand.")).toBeInTheDocument();
23
27
  expect(screen.getByText("body")).toBeInTheDocument();
24
28
  });
package/src/ui/Card.tsx CHANGED
@@ -1,9 +1,12 @@
1
- import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
2
 
3
3
  import type { SpaceToken } from "../layout";
4
+ import { injectTerpStyles } from "../styles";
4
5
  import { useUiText } from "../uiText";
5
6
  import type { UiText } from "../uiText";
6
7
 
8
+ injectTerpStyles();
9
+
7
10
  export interface CardProps
8
11
  extends Omit<HTMLAttributes<HTMLElement>, "style" | "title"> {
9
12
  /** Optional section heading, rendered as an `<h3>` in the card's header row. */
@@ -19,37 +22,6 @@ export interface CardProps
19
22
  children?: ReactNode;
20
23
  }
21
24
 
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
25
  /**
54
26
  * A token-styled surface that groups one block of a page — the sanctioned way to give
55
27
  * sections visual separation (border + background + padding) without module CSS, and
@@ -70,23 +42,23 @@ export function Card({
70
42
  const resolve = useUiText();
71
43
  const hasHeader = title !== undefined || actions !== undefined;
72
44
  return (
73
- <Component {...rest} data-terp="card" style={{ ...cardStyle, gap: `var(--space-${gap})` }}>
45
+ <Component {...rest} data-terp="card" data-gap={String(gap)}>
74
46
  {hasHeader ? (
75
- <div data-terp="card-header" style={headerStyle}>
76
- <div style={{ minWidth: 0 }}>
77
- {title !== undefined ? <h3 style={titleStyle}>{resolve(title)}</h3> : null}
47
+ <div data-terp="card-header">
48
+ <div data-terp="card-heading">
49
+ {title !== undefined ? <h3 data-terp="card-title">{resolve(title)}</h3> : null}
78
50
  {description !== undefined ? (
79
- <p style={descriptionStyle}>{resolve(description)}</p>
51
+ <p data-terp="card-description">{resolve(description)}</p>
80
52
  ) : null}
81
53
  </div>
82
54
  {actions !== undefined ? (
83
- <div data-terp="card-actions" style={{ flexShrink: 0 }}>
55
+ <div data-terp="card-actions">
84
56
  {actions}
85
57
  </div>
86
58
  ) : null}
87
59
  </div>
88
60
  ) : description !== undefined ? (
89
- <p style={descriptionStyle}>{resolve(description)}</p>
61
+ <p data-terp="card-description">{resolve(description)}</p>
90
62
  ) : null}
91
63
  {children}
92
64
  </Component>
@@ -1,4 +1,4 @@
1
- import type { CSSProperties, InputHTMLAttributes } from "react";
1
+ import type { InputHTMLAttributes } from "react";
2
2
 
3
3
  import { injectTerpStyles } from "../styles";
4
4
  import { useUiText } from "../uiText";
@@ -6,22 +6,6 @@ import type { UiText } from "../uiText";
6
6
 
7
7
  injectTerpStyles();
8
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
9
  export interface CheckboxProps
26
10
  extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "defaultChecked" | "onChange"> {
27
11
  label: UiText;
@@ -34,7 +18,7 @@ export interface CheckboxProps
34
18
  export function Checkbox({ label, checked, defaultChecked, onChange, style, ...rest }: CheckboxProps) {
35
19
  const resolve = useUiText();
36
20
  return (
37
- <label style={{ ...labelStyle, ...style }}>
21
+ <label data-terp="control-label" style={style}>
38
22
  <input
39
23
  {...rest}
40
24
  type="checkbox"
@@ -42,7 +26,6 @@ export function Checkbox({ label, checked, defaultChecked, onChange, style, ...r
42
26
  checked={checked}
43
27
  defaultChecked={defaultChecked}
44
28
  onChange={(event) => onChange?.(event.currentTarget.checked)}
45
- style={inputStyle}
46
29
  />
47
30
  <span>{resolve(label)}</span>
48
31
  </label>
@@ -55,4 +55,26 @@ describe("Combobox", () => {
55
55
  expect(screen.getByRole("combobox", { name: "Assignee" })).toBeDisabled();
56
56
  expect(screen.queryByRole("status")).not.toBeInTheDocument();
57
57
  });
58
+
59
+ it("opens on mount with the cursor on the selection when asked", () => {
60
+ // The prop exists so the listbox can be rendered deterministically — it had no way in
61
+ // at all before, which is why sixteen sheet rules for this subtree went unpainted by
62
+ // both visual lanes from the moment they were written.
63
+ render(<Combobox aria-label="Country" value="be" options={options} defaultOpen />);
64
+ const listbox = screen.getByRole("listbox");
65
+ expect(listbox).toBeInTheDocument();
66
+ expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "true");
67
+ // The cursor lands on the selection rather than nowhere: an open list with no active
68
+ // option is a state focusing the box never produces.
69
+ const active = listbox.querySelector('[data-active="true"]');
70
+ expect(active?.textContent).toBe("Belgium");
71
+ expect(active).toHaveAttribute("aria-selected", "true");
72
+ });
73
+
74
+ it("keeps the listbox shut when disabled, even with defaultOpen", () => {
75
+ render(
76
+ <Combobox aria-label="Country" value="be" options={options} defaultOpen disabled />,
77
+ );
78
+ expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
79
+ });
58
80
  });
@@ -1,10 +1,9 @@
1
1
  import { useEffect, useId, useMemo, useRef, useState } from "react";
2
- import type { CSSProperties, InputHTMLAttributes, KeyboardEvent } from "react";
2
+ import type { InputHTMLAttributes, KeyboardEvent } from "react";
3
3
 
4
4
  import { injectTerpStyles } from "../styles";
5
5
  import { useUiText } from "../uiText";
6
6
  import type { UiText } from "../uiText";
7
- import { CONTROL_TEXT_STYLE } from "./controlStyles";
8
7
 
9
8
  injectTerpStyles();
10
9
 
@@ -24,73 +23,16 @@ export interface ComboboxProps
24
23
  loadingText?: UiText;
25
24
  noOptionsText?: UiText;
26
25
  clearable?: boolean;
26
+ /**
27
+ * Open the listbox on mount (uncontrolled), the same shape `Popover` and `Menu` take.
28
+ *
29
+ * The cursor starts on the selection rather than nowhere, which is what focusing the box
30
+ * does — an already-open list with no active option would be a state the component cannot
31
+ * otherwise reach.
32
+ */
33
+ defaultOpen?: boolean;
27
34
  }
28
35
 
29
- const wrapperStyle: CSSProperties = { position: "relative", display: "grid" };
30
- const inputWrapStyle: CSSProperties = { position: "relative", display: "grid" };
31
- const inputStyle: CSSProperties = {
32
- ...CONTROL_TEXT_STYLE,
33
- lineHeight: 1.2,
34
- width: "100%",
35
- minWidth: 0,
36
- minHeight: "2.25rem",
37
- padding: "0 calc(var(--space-3) + 1.5rem) 0 var(--space-3)",
38
- border: "1px solid var(--color-neutral-300)",
39
- borderRadius: "var(--radius-md)",
40
- color: "var(--color-neutral-900)",
41
- background: "var(--color-neutral-0)",
42
- boxSizing: "border-box",
43
- };
44
- const clearStyle: CSSProperties = {
45
- position: "absolute",
46
- insetInlineEnd: "var(--space-1)",
47
- insetBlockStart: "50%",
48
- transform: "translateY(-50%)",
49
- border: "none",
50
- background: "transparent",
51
- color: "var(--color-neutral-500)",
52
- cursor: "pointer",
53
- minWidth: "1.75rem",
54
- minHeight: "1.75rem",
55
- borderRadius: "var(--radius-sm)",
56
- };
57
- const listStyle: CSSProperties = {
58
- position: "absolute",
59
- insetInlineStart: 0,
60
- insetInlineEnd: 0,
61
- insetBlockStart: "calc(100% + var(--space-1))",
62
- zIndex: 50,
63
- display: "grid",
64
- gap: "var(--space-1)",
65
- maxHeight: "16rem",
66
- overflowY: "auto",
67
- padding: "var(--space-1)",
68
- background: "var(--color-neutral-0)",
69
- border: "1px solid var(--color-neutral-200)",
70
- borderRadius: "var(--radius-lg)",
71
- boxShadow: "var(--shadow-lg)",
72
- };
73
- const optionStyle = (active: boolean, selected: boolean, disabled: boolean): CSSProperties => ({
74
- ...CONTROL_TEXT_STYLE,
75
- textAlign: "left",
76
- padding: "var(--space-2) var(--space-3)",
77
- border: "none",
78
- borderRadius: "var(--radius-sm)",
79
- background: active ? "var(--color-neutral-100)" : "transparent",
80
- color: disabled
81
- ? "var(--color-neutral-400)"
82
- : selected
83
- ? "var(--color-brand-primary)"
84
- : "var(--color-neutral-900)",
85
- cursor: disabled ? "not-allowed" : "pointer",
86
- fontWeight: selected ? "var(--font-weight-semibold)" as never : "var(--font-weight-normal)" as never,
87
- });
88
- const emptyStyle: CSSProperties = {
89
- padding: "var(--space-2) var(--space-3)",
90
- color: "var(--color-neutral-500)",
91
- fontSize: "var(--font-size-sm)",
92
- };
93
-
94
36
  /** Filterable ARIA combobox/typeahead with controlled or uncontrolled single selection. */
95
37
  export function Combobox({
96
38
  options,
@@ -101,6 +43,7 @@ export function Combobox({
101
43
  loadingText = "Loading…",
102
44
  noOptionsText = "No options",
103
45
  clearable = false,
46
+ defaultOpen = false,
104
47
  disabled,
105
48
  onBlur,
106
49
  onFocus,
@@ -117,8 +60,16 @@ export function Combobox({
117
60
  const selectedValue = value ?? uncontrolledValue;
118
61
  const selectedOption = options.find((option) => option.value === selectedValue) ?? null;
119
62
  const [query, setQuery] = useState(() => (selectedOption ? resolve(selectedOption.label) : ""));
120
- const [open, setOpen] = useState(false);
121
- const [activeValue, setActiveValue] = useState<string | null>(null);
63
+ const [open, setOpen] = useState(defaultOpen);
64
+ const [activeValue, setActiveValue] = useState<string | null>(
65
+ defaultOpen ? selectedOption?.value ?? null : null,
66
+ );
67
+
68
+ // What the DOM should say, as opposed to what the state happens to hold. The listbox render
69
+ // was already guarded on `disabled` while aria-expanded was not, so a disabled combobox seeded
70
+ // open advertised role="combobox" aria-expanded="true" with aria-controls pointing at an id
71
+ // that is not in the document. Derived once, so the three cannot drift apart again.
72
+ const isOpen = open && disabled !== true;
122
73
 
123
74
  const renderedOptions = useMemo(() => {
124
75
  const normalized = query.trim().toLocaleLowerCase();
@@ -227,17 +178,17 @@ export function Combobox({
227
178
  }
228
179
 
229
180
  return (
230
- <div ref={rootRef} style={wrapperStyle}>
231
- <div style={inputWrapStyle}>
181
+ <div ref={rootRef} data-terp="combobox">
182
+ <div data-terp="combobox-field">
232
183
  <input
233
184
  {...rest}
234
185
  ref={inputRef}
235
186
  data-terp="input"
236
187
  role="combobox"
237
188
  aria-autocomplete="list"
238
- aria-expanded={open}
189
+ aria-expanded={isOpen}
239
190
  aria-controls={`${baseId}-listbox`}
240
- aria-activedescendant={open && activeOption !== null ? `${baseId}-option-${activeOption.value}` : undefined}
191
+ aria-activedescendant={isOpen && activeOption !== null ? `${baseId}-option-${activeOption.value}` : undefined}
241
192
  aria-invalid={rest["aria-invalid"]}
242
193
  disabled={disabled}
243
194
  placeholder={placeholder}
@@ -259,7 +210,7 @@ export function Combobox({
259
210
  }
260
211
  }}
261
212
  onKeyDown={handleKeyDown}
262
- style={{ ...inputStyle, ...style }}
213
+ style={style}
263
214
  />
264
215
  {clearable && !disabled && query.length > 0 && (
265
216
  <button
@@ -270,18 +221,17 @@ export function Combobox({
270
221
  commit(null);
271
222
  inputRef.current?.focus();
272
223
  }}
273
- style={clearStyle}
274
224
  >
275
225
  ×
276
226
  </button>
277
227
  )}
278
228
  </div>
279
- {open && !disabled && (
280
- <div id={`${baseId}-listbox`} role="listbox" style={listStyle}>
229
+ {isOpen && (
230
+ <div id={`${baseId}-listbox`} role="listbox" data-terp="combobox-list">
281
231
  {loading ? (
282
- <div role="status" style={emptyStyle}>{resolve(loadingText)}</div>
232
+ <div role="status" data-terp="combobox-empty">{resolve(loadingText)}</div>
283
233
  ) : renderedOptions.length === 0 ? (
284
- <div style={emptyStyle}>{resolve(noOptionsText)}</div>
234
+ <div data-terp="combobox-empty">{resolve(noOptionsText)}</div>
285
235
  ) : (
286
236
  renderedOptions.map((option) => {
287
237
  const label = resolve(option.label);
@@ -299,7 +249,8 @@ export function Combobox({
299
249
  onMouseDown={(event) => event.preventDefault()}
300
250
  onMouseEnter={() => setActiveValue(option.value)}
301
251
  onClick={() => commit(option)}
302
- style={optionStyle(active, selected, option.disabled === true)}
252
+ data-terp="combobox-option"
253
+ data-active={active ? "true" : undefined}
303
254
  >
304
255
  {label}
305
256
  </button>
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment jsdom
2
- import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
4
 
5
5
  import { LOCALE_EN, LOCALE_NL, LocaleProvider } from "../locale";
@@ -27,6 +27,110 @@ describe("DatePicker", () => {
27
27
  expect(screen.queryByRole("grid")).not.toBeInTheDocument();
28
28
  });
29
29
 
30
+ // The ARIA shape and the roving cursor, both of which were wrong and neither of which
31
+ // any lane could see: the calendar is only reachable through an open Popover, nothing in
32
+ // the repo opened one, and the visual baselines capture resting state only.
33
+ it("renders a valid grid of week rows, not 42 cells hanging off the grid", () => {
34
+ render(
35
+ <LocaleProvider locales={{ en: LOCALE_EN }}>
36
+ <DatePicker aria-label="Due date" defaultValue={new Date(2026, 6, 7)} />
37
+ </LocaleProvider>,
38
+ );
39
+ fireEvent.click(screen.getByRole("button", { name: "Due date" }));
40
+ const grid = screen.getByRole("grid");
41
+ const rows = screen.getAllByRole("row");
42
+ expect(rows).toHaveLength(6);
43
+ // Owned by the grid, and owning the cells: `grid` requires `row` children and a
44
+ // `gridcell` requires a `row` parent, so a flat grid fails both rules at once. Asserted
45
+ // as a DOM relationship rather than a count, because 42 cells in the tree somewhere is
46
+ // what the invalid version had too.
47
+ for (const row of rows) {
48
+ expect(row.parentElement).toBe(grid);
49
+ expect(row.querySelectorAll('[role="gridcell"]')).toHaveLength(7);
50
+ }
51
+ expect(grid.querySelectorAll(':scope > [role="gridcell"]')).toHaveLength(0);
52
+ });
53
+
54
+ it("moves DOM focus with the roving cursor, not just tabIndex", async () => {
55
+ render(
56
+ <LocaleProvider locales={{ en: LOCALE_EN }}>
57
+ <DatePicker aria-label="Due date" defaultValue={new Date(2026, 6, 7)} />
58
+ </LocaleProvider>,
59
+ );
60
+ fireEvent.click(screen.getByRole("button", { name: "Due date" }));
61
+ const grid = screen.getByRole("grid");
62
+ const cursor = () => grid.querySelector<HTMLElement>('[tabindex="0"]');
63
+
64
+ // Opening focuses the cursor (deferred a tick — the panel is portalled).
65
+ await waitFor(() => expect(document.activeElement).toBe(cursor()));
66
+ expect(cursor()?.textContent).toBe("7");
67
+
68
+ fireEvent.keyDown(grid, { key: "ArrowRight" });
69
+ expect(cursor()?.textContent).toBe("8");
70
+ // The bug this pins: tabIndex moved and the browser's focus did not, so the focus ring
71
+ // and every screen reader stayed on the day the calendar opened on.
72
+ expect(document.activeElement).toBe(cursor());
73
+
74
+ fireEvent.keyDown(grid, { key: "ArrowDown" });
75
+ expect(cursor()?.textContent).toBe("15");
76
+ expect(document.activeElement).toBe(cursor());
77
+ });
78
+
79
+ it("does not pull focus off the month buttons when they move the cursor", async () => {
80
+ render(
81
+ <LocaleProvider locales={{ en: LOCALE_EN }}>
82
+ <DatePicker aria-label="Due date" defaultValue={new Date(2026, 6, 7)} />
83
+ </LocaleProvider>,
84
+ );
85
+ fireEvent.click(screen.getByRole("button", { name: "Due date" }));
86
+ await waitFor(() =>
87
+ expect(document.activeElement).toBe(
88
+ screen.getByRole("grid").querySelector('[tabindex="0"]'),
89
+ ),
90
+ );
91
+ // Changing month also moves the cursor. The follow is scoped to the grid rather than
92
+ // the whole calendar precisely so this button keeps the focus the pointer gave it.
93
+ const next = screen.getByRole("button", { name: "Next month" });
94
+ next.focus();
95
+ fireEvent.click(next);
96
+ expect(screen.getByRole("grid", { name: /August 2026/ })).toBeInTheDocument();
97
+ expect(document.activeElement).toBe(next);
98
+ });
99
+
100
+ it("opens the calendar on mount when asked", () => {
101
+ // Sixteen calendar rules have been in the sheet since stage 2c with no way to render the
102
+ // subtree, so neither visual lane had ever painted one. This is the way in.
103
+ render(
104
+ <LocaleProvider locales={{ en: LOCALE_EN }}>
105
+ <DatePicker aria-label="Due date" value={new Date(2026, 6, 7)} defaultOpen />
106
+ </LocaleProvider>,
107
+ );
108
+ expect(screen.getByRole("grid", { name: /July 2026/ })).toBeInTheDocument();
109
+ expect(screen.getByRole("button", { name: "Due date" })).toHaveAttribute(
110
+ "aria-expanded",
111
+ "true",
112
+ );
113
+ });
114
+
115
+ it("names the calendar dialog with its month, and each day with its whole date", () => {
116
+ render(
117
+ <LocaleProvider locales={{ en: LOCALE_EN }}>
118
+ <DatePicker aria-label="Due date" value={new Date(2026, 6, 7)} defaultOpen />
119
+ </LocaleProvider>,
120
+ );
121
+ // A role="dialog" with no accessible name announces itself as "dialog" and nothing else.
122
+ // The month was one level down on the grid, so it was reached only after the boundary had
123
+ // already been crossed unnamed. axe does not report this at the wcag2a/aa tags the visual
124
+ // suite runs, so opening the calendar did not surface it.
125
+ expect(screen.getByRole("dialog", { name: /July 2026/ })).toBeInTheDocument();
126
+ // And a day cell's visible text is a bare number, while the weekday row is aria-hidden AND
127
+ // a sibling of the grid rather than columnheaders inside it — so a cell had no weekday, no
128
+ // month and no year to announce.
129
+ expect(
130
+ screen.getByRole("gridcell", { name: "Tuesday, July 7, 2026" }),
131
+ ).toBeInTheDocument();
132
+ });
133
+
30
134
  it("uses the active locale for month and weekday names", () => {
31
135
  render(
32
136
  <LocaleProvider locales={{ nl: LOCALE_NL }}>
@@ -40,11 +144,34 @@ describe("DatePicker", () => {
40
144
  });
41
145
 
42
146
  describe("DateRangePicker", () => {
147
+ it("opens the calendar on mount when asked", () => {
148
+ render(
149
+ <DateRangePicker
150
+ aria-label="Window"
151
+ value={{ start: new Date(2026, 6, 10), end: new Date(2026, 6, 14) }}
152
+ defaultOpen
153
+ />,
154
+ );
155
+ expect(screen.getByRole("grid")).toBeInTheDocument();
156
+ // Both endpoints are selected and the days between them carry the range attribute — the
157
+ // only surface in the package that paints either, and now the only one with a baseline.
158
+ expect(screen.getByRole("grid").querySelectorAll('[aria-selected="true"]')).toHaveLength(2);
159
+ expect(
160
+ screen.getByRole("grid").querySelectorAll('[data-in-range="true"]').length,
161
+ ).toBeGreaterThan(0);
162
+ });
163
+
164
+ // Day cells are matched by the text a user sees rather than by accessible name: the name is
165
+ // now the whole date, and these two tests mount no LocaleProvider, so a name-based query
166
+ // would depend on the host machine's locale.
167
+ const dayCells = (text: string) =>
168
+ screen.getAllByRole("gridcell").filter((cell) => cell.textContent === text);
169
+
43
170
  it("selects a start/end range and closes after the end", () => {
44
171
  const onChange = vi.fn();
45
172
  render(<DateRangePicker aria-label="Window" defaultValue={{ start: new Date(2026, 6, 10), end: null }} onChange={onChange} />);
46
173
  fireEvent.click(screen.getByRole("button", { name: "Window" }));
47
- fireEvent.click(screen.getByRole("gridcell", { name: "12" }));
174
+ fireEvent.click(dayCells("12")[0]!);
48
175
  expect(onChange).toHaveBeenCalledWith({ start: new Date(2026, 6, 10), end: new Date(2026, 6, 12) });
49
176
  expect(screen.queryByRole("grid")).not.toBeInTheDocument();
50
177
  });
@@ -61,8 +188,8 @@ describe("DateRangePicker", () => {
61
188
  />,
62
189
  );
63
190
  fireEvent.click(screen.getByRole("button", { name: "Window" }));
64
- expect(screen.getAllByRole("gridcell", { name: "4" })[0]).toBeDisabled();
65
- fireEvent.click(screen.getAllByRole("gridcell", { name: "8" })[0]);
191
+ expect(dayCells("4")[0]).toBeDisabled();
192
+ fireEvent.click(dayCells("8")[0]!);
66
193
  expect(onChange).toHaveBeenCalledWith({ start: new Date(2026, 6, 8), end: null });
67
194
  });
68
195
  });