@terpjs/react-core 0.7.0 → 0.9.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 (94) hide show
  1. package/README.md +6 -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/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  7. package/src/Breadcrumbs.test.tsx +24 -0
  8. package/src/Breadcrumbs.tsx +9 -32
  9. package/src/ConfirmDialog.tsx +13 -44
  10. package/src/EmptyState.tsx +8 -36
  11. package/src/ErrorState.tsx +8 -36
  12. package/src/Field.test.tsx +57 -0
  13. package/src/Field.tsx +46 -22
  14. package/src/HubPage.test.tsx +22 -13
  15. package/src/HubPage.tsx +25 -97
  16. package/src/LoadingState.tsx +3 -24
  17. package/src/LoginView.tsx +22 -75
  18. package/src/ModuleNav.test.tsx +19 -0
  19. package/src/ModuleNav.tsx +10 -35
  20. package/src/Page.test.tsx +9 -6
  21. package/src/Page.tsx +15 -39
  22. package/src/PageActions.tsx +5 -10
  23. package/src/ProfileView.test.tsx +15 -0
  24. package/src/ProfileView.tsx +8 -33
  25. package/src/ResourceList.tsx +13 -24
  26. package/src/UserMenu.test.tsx +12 -5
  27. package/src/UserMenu.tsx +33 -62
  28. package/src/admin/AuditLogAdmin.tsx +1 -10
  29. package/src/admin/GroupCreate.tsx +1 -1
  30. package/src/admin/GroupDetail.tsx +2 -2
  31. package/src/admin/UserCreate.tsx +1 -1
  32. package/src/admin/admin.test.tsx +31 -0
  33. package/src/dataview/DataView.test.tsx +109 -5
  34. package/src/dataview/DataView.tsx +41 -23
  35. package/src/dataview/DataViewCardList.tsx +14 -60
  36. package/src/dataview/DataViewColumnSettings.tsx +46 -51
  37. package/src/dataview/DataViewExpandableRow.tsx +2 -17
  38. package/src/dataview/DataViewPagination.tsx +2 -32
  39. package/src/dataview/DataViewRowActions.tsx +13 -33
  40. package/src/dataview/DataViewTable.tsx +16 -103
  41. package/src/dataview/DataViewToolbar.tsx +53 -76
  42. package/src/dataview/README.md +6 -0
  43. package/src/dataview/index.ts +1 -0
  44. package/src/dataview/internal.tsx +4 -1
  45. package/src/dataview/types.ts +13 -0
  46. package/src/download.test.tsx +153 -0
  47. package/src/download.tsx +132 -0
  48. package/src/feedback.test.tsx +26 -0
  49. package/src/files.test.tsx +18 -0
  50. package/src/files.tsx +15 -15
  51. package/src/icons.test.tsx +10 -6
  52. package/src/icons.tsx +9 -37
  53. package/src/index.ts +7 -4
  54. package/src/layout.test.tsx +24 -9
  55. package/src/layout.tsx +24 -21
  56. package/src/layoutContract.test.tsx +95 -0
  57. package/src/locale.tsx +24 -4
  58. package/src/markers.test.ts +354 -25
  59. package/src/routeSearch.ts +73 -0
  60. package/src/routeTypes.ts +50 -6
  61. package/src/router.test.tsx +191 -1
  62. package/src/router.tsx +81 -18
  63. package/src/sso.test.tsx +6 -3
  64. package/src/ssr.test.tsx +1 -3
  65. package/src/styles.test.ts +855 -6
  66. package/src/styles.ts +3049 -153
  67. package/src/theme.tsx +24 -3
  68. package/src/toast.tsx +35 -71
  69. package/src/ui/Alert.test.tsx +12 -0
  70. package/src/ui/Alert.tsx +15 -43
  71. package/src/ui/Badge.test.tsx +14 -3
  72. package/src/ui/Badge.tsx +9 -28
  73. package/src/ui/Button.test.tsx +19 -4
  74. package/src/ui/Button.tsx +10 -63
  75. package/src/ui/Card.test.tsx +6 -2
  76. package/src/ui/Card.tsx +11 -39
  77. package/src/ui/Checkbox.tsx +2 -19
  78. package/src/ui/Combobox.test.tsx +22 -0
  79. package/src/ui/Combobox.tsx +31 -80
  80. package/src/ui/DatePicker.test.tsx +131 -4
  81. package/src/ui/DatePicker.tsx +158 -106
  82. package/src/ui/Input.tsx +6 -19
  83. package/src/ui/Markdown.test.tsx +26 -0
  84. package/src/ui/Markdown.tsx +28 -2
  85. package/src/ui/Menu.test.tsx +38 -4
  86. package/src/ui/Menu.tsx +50 -52
  87. package/src/ui/Popover.tsx +53 -19
  88. package/src/ui/Radio.tsx +5 -30
  89. package/src/ui/Select.tsx +7 -30
  90. package/src/ui/Switch.tsx +2 -20
  91. package/src/ui/Tabs.tsx +4 -28
  92. package/src/ui/Textarea.tsx +6 -17
  93. package/src/ui/Tooltip.tsx +9 -21
  94. package/src/ui/controlStyles.ts +0 -9
@@ -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-fg-accent)",
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-fg-accent)"
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
  });