@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,11 +1,10 @@
1
1
  import { useEffect, useId, useMemo, useRef, useState } from "react";
2
- import type { CSSProperties, KeyboardEvent } from "react";
2
+ import type { KeyboardEvent } from "react";
3
3
 
4
4
  import { useLocale } from "../locale";
5
5
  import { injectTerpStyles } from "../styles";
6
6
  import { useUiText } from "../uiText";
7
7
  import type { UiText } from "../uiText";
8
- import { CONTROL_TEXT_STYLE } from "./controlStyles";
9
8
  import { Popover } from "./Popover";
10
9
 
11
10
  injectTerpStyles();
@@ -25,6 +24,8 @@ export interface DatePickerProps {
25
24
  placeholder?: UiText;
26
25
  "aria-label"?: string;
27
26
  "aria-invalid"?: boolean | "true" | "false";
27
+ /** Open the calendar on mount (uncontrolled), the same shape `Popover` and `Menu` take. */
28
+ defaultOpen?: boolean;
28
29
  }
29
30
 
30
31
  export interface DateRangePickerProps {
@@ -37,61 +38,10 @@ export interface DateRangePickerProps {
37
38
  placeholder?: UiText;
38
39
  "aria-label"?: string;
39
40
  "aria-invalid"?: boolean | "true" | "false";
41
+ /** Open the calendar on mount (uncontrolled), the same shape `Popover` and `Menu` take. */
42
+ defaultOpen?: boolean;
40
43
  }
41
44
 
42
- const triggerStyle: CSSProperties = {
43
- ...CONTROL_TEXT_STYLE,
44
- lineHeight: 1.2,
45
- minHeight: "2.25rem",
46
- width: "100%",
47
- display: "inline-flex",
48
- alignItems: "center",
49
- justifyContent: "space-between",
50
- gap: "var(--space-2)",
51
- padding: "0 var(--space-3)",
52
- border: "1px solid var(--color-neutral-300)",
53
- borderRadius: "var(--radius-md)",
54
- color: "var(--color-neutral-900)",
55
- background: "var(--color-neutral-0)",
56
- boxSizing: "border-box",
57
- cursor: "pointer",
58
- };
59
- const calendarStyle: CSSProperties = { display: "grid", gap: "var(--space-2)", minWidth: "18rem" };
60
- const headerStyle: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "var(--space-2)" };
61
- const titleStyle: CSSProperties = { fontWeight: "var(--font-weight-semibold)" as never, color: "var(--color-neutral-900)" };
62
- const navButtonStyle: CSSProperties = {
63
- display: "inline-flex",
64
- alignItems: "center",
65
- justifyContent: "center",
66
- width: "2rem",
67
- height: "2rem",
68
- border: "1px solid var(--color-neutral-300)",
69
- borderRadius: "var(--radius-md)",
70
- background: "transparent",
71
- color: "var(--color-neutral-700)",
72
- cursor: "pointer",
73
- };
74
- const weekStyle: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: "var(--space-1)" };
75
- const weekdayStyle: CSSProperties = { textAlign: "center", fontSize: "var(--font-size-xs)", color: "var(--color-neutral-500)" };
76
- const dayStyle = (selected: boolean, inRange: boolean, disabled: boolean): CSSProperties => ({
77
- ...CONTROL_TEXT_STYLE,
78
- minHeight: "2rem",
79
- border: selected ? "1px solid var(--color-brand-primary)" : "1px solid transparent",
80
- borderRadius: "var(--radius-md)",
81
- background: selected
82
- ? "var(--color-brand-primary)"
83
- : inRange
84
- ? "var(--color-brand-primary-soft)"
85
- : "transparent",
86
- color: disabled
87
- ? "var(--color-neutral-300)"
88
- : selected
89
- ? "var(--color-brand-primary-contrast)"
90
- : "var(--color-neutral-900)",
91
- cursor: disabled ? "not-allowed" : "pointer",
92
- });
93
- const mutedDayStyle: CSSProperties = { opacity: 0.45 };
94
-
95
45
  /** Single-date calendar picker with locale-aware labels and keyboard navigation. */
96
46
  export function DatePicker({
97
47
  value,
@@ -103,10 +53,17 @@ export function DatePicker({
103
53
  placeholder = "Select date",
104
54
  "aria-label": ariaLabel,
105
55
  "aria-invalid": ariaInvalid,
56
+ defaultOpen = false,
106
57
  }: DatePickerProps) {
107
58
  const [uncontrolledValue, setUncontrolledValue] = useState<Date | null>(defaultValue);
108
59
  const selected = normalizeDate(value ?? uncontrolledValue);
109
- const [open, setOpen] = useState(false);
60
+ // `&& !disabled` is not belt-and-braces. Every close path runs through
61
+ // onOpenChange, which swallows the change while disabled — so a disabled picker seeded
62
+ // open would render a calendar that Escape, an outside click and its own onEscape all
63
+ // fail to dismiss, and whose day clicks still fire onChange from a control the app
64
+ // marked disabled. Only reachable since defaultOpen existed: before it, the sole way in
65
+ // was clicking the disabled trigger.
66
+ const [open, setOpen] = useState(defaultOpen && !disabled);
110
67
  const locale = useDateLocale();
111
68
  const resolve = useUiText();
112
69
  const formatted = selected === null ? resolve(placeholder) : formatDate(selected, locale);
@@ -121,7 +78,7 @@ export function DatePicker({
121
78
 
122
79
  return (
123
80
  <Popover
124
- open={open}
81
+ open={open && !disabled}
125
82
  onOpenChange={(next) => !disabled && setOpen(next)}
126
83
  align="start"
127
84
  trigger={
@@ -132,7 +89,7 @@ export function DatePicker({
132
89
  aria-label={ariaLabel}
133
90
  aria-invalid={ariaInvalid}
134
91
  disabled={disabled}
135
- style={{ ...triggerStyle, color: selected === null ? "var(--color-neutral-500)" : triggerStyle.color }}
92
+ data-placeholder={selected === null ? "true" : undefined}
136
93
  >
137
94
  <span>{formatted}</span>
138
95
  <span aria-hidden="true">📅</span>
@@ -166,10 +123,17 @@ export function DateRangePicker({
166
123
  placeholder = "Select date range",
167
124
  "aria-label": ariaLabel,
168
125
  "aria-invalid": ariaInvalid,
126
+ defaultOpen = false,
169
127
  }: DateRangePickerProps) {
170
128
  const [uncontrolledValue, setUncontrolledValue] = useState<DateRangeValue>(defaultValue);
171
129
  const selected = normalizeRange(value ?? uncontrolledValue);
172
- const [open, setOpen] = useState(false);
130
+ // `&& !disabled` is not belt-and-braces. Every close path runs through
131
+ // onOpenChange, which swallows the change while disabled — so a disabled picker seeded
132
+ // open would render a calendar that Escape, an outside click and its own onEscape all
133
+ // fail to dismiss, and whose day clicks still fire onChange from a control the app
134
+ // marked disabled. Only reachable since defaultOpen existed: before it, the sole way in
135
+ // was clicking the disabled trigger.
136
+ const [open, setOpen] = useState(defaultOpen && !disabled);
173
137
  const locale = useDateLocale();
174
138
  const resolve = useUiText();
175
139
  const formatted = selected.start === null
@@ -190,7 +154,7 @@ export function DateRangePicker({
190
154
 
191
155
  return (
192
156
  <Popover
193
- open={open}
157
+ open={open && !disabled}
194
158
  onOpenChange={(next) => !disabled && setOpen(next)}
195
159
  align="start"
196
160
  trigger={
@@ -201,7 +165,7 @@ export function DateRangePicker({
201
165
  aria-label={ariaLabel}
202
166
  aria-invalid={ariaInvalid}
203
167
  disabled={disabled}
204
- style={{ ...triggerStyle, color: selected.start === null ? "var(--color-neutral-500)" : triggerStyle.color }}
168
+ data-placeholder={selected.start === null ? "true" : undefined}
205
169
  >
206
170
  <span>{formatted}</span>
207
171
  <span aria-hidden="true">📅</span>
@@ -239,19 +203,61 @@ interface CalendarProps {
239
203
 
240
204
  function Calendar({ mode, locale, visibleSeed, selected = null, range, min, max, onSelect, onRangeSelect, onEscape }: CalendarProps) {
241
205
  const gridId = useId();
206
+ const titleId = useId();
242
207
  const minDate = normalizeDate(min);
243
208
  const maxDate = normalizeDate(max);
244
209
  const initial = clampDate(normalizeDate(visibleSeed) ?? today(), minDate, maxDate);
245
210
  const [month, setMonth] = useState(() => startOfMonth(initial));
246
211
  const [activeDate, setActiveDate] = useState(initial);
247
212
  const activeRef = useRef<HTMLButtonElement>(null);
248
- const days = useMemo(() => monthGrid(month), [month]);
213
+ const follow = useRef(false);
214
+ const weeks = useMemo(() => monthWeeks(month), [month]);
249
215
  const weekdays = useMemo(() => weekdayNames(locale), [locale]);
216
+ const activeKey = toKey(activeDate);
250
217
 
218
+ // Opening: focus the cursor once the portalled panel exists where it will finally sit
219
+ // (Popover positions it in a layout effect, hence the tick). Its own effect with its own
220
+ // cleanup, deliberately — folding it into the cursor effect below and gating it on a
221
+ // did-mount ref meant StrictMode's mount / cleanup / mount cycle cleared the first timer
222
+ // and then took the other branch, so in development the calendar opened with focus outside
223
+ // the grid and the arrow keys inert.
251
224
  useEffect(() => {
252
- window.setTimeout(() => activeRef.current?.focus(), 0);
225
+ const timer = window.setTimeout(() => activeRef.current?.focus(), 0);
226
+ return () => window.clearTimeout(timer);
253
227
  }, []);
254
228
 
229
+ // The roving cursor has to move DOM focus, not just `tabIndex` — an arrow key used to move
230
+ // `tabIndex={0}` and `activeRef` to another cell while the browser's focus, the focus ring
231
+ // and every screen reader stayed on the day the calendar opened on.
232
+ //
233
+ // The intent is recorded at the EVENT rather than inferred afterwards, and that is the whole
234
+ // correctness argument. Reading `document.activeElement` after the commit looks equivalent
235
+ // and is not: a move across a month boundary re-keys the week rows, so the row holding the
236
+ // focused cell is unmounted, which moves focus to <body> — and a post-commit check then sees
237
+ // focus outside the grid and declines to follow. Measured in a browser: arrowing from
238
+ // 29 January to 5 February left `document.activeElement` on BODY and the calendar
239
+ // keyboard-dead, because the grid's handler is the only key listener. The flag cannot be
240
+ // fooled that way, because only the grid's own handler sets it.
241
+ useEffect(() => {
242
+ if (!follow.current) {
243
+ return;
244
+ }
245
+ follow.current = false;
246
+ activeRef.current?.focus();
247
+ }, [activeKey]);
248
+
249
+ /** Move the cursor from a key the grid handled, and take focus with it. */
250
+ function moveCursor(daysDelta: number) {
251
+ follow.current = true;
252
+ move(daysDelta);
253
+ }
254
+
255
+ /** Page the month from a key the grid handled. The header buttons deliberately do not. */
256
+ function pageMonth(delta: number) {
257
+ follow.current = true;
258
+ changeMonth(delta);
259
+ }
260
+
255
261
  function move(daysDelta: number) {
256
262
  const next = clampDate(addDays(activeDate, daysDelta), minDate, maxDate);
257
263
  setActiveDate(next);
@@ -290,35 +296,35 @@ function Calendar({ mode, locale, visibleSeed, selected = null, range, min, max,
290
296
  break;
291
297
  case "ArrowRight":
292
298
  event.preventDefault();
293
- move(1);
299
+ moveCursor(1);
294
300
  break;
295
301
  case "ArrowLeft":
296
302
  event.preventDefault();
297
- move(-1);
303
+ moveCursor(-1);
298
304
  break;
299
305
  case "ArrowDown":
300
306
  event.preventDefault();
301
- move(7);
307
+ moveCursor(7);
302
308
  break;
303
309
  case "ArrowUp":
304
310
  event.preventDefault();
305
- move(-7);
311
+ moveCursor(-7);
306
312
  break;
307
313
  case "Home":
308
314
  event.preventDefault();
309
- move(-activeDate.getDay());
315
+ moveCursor(-activeDate.getDay());
310
316
  break;
311
317
  case "End":
312
318
  event.preventDefault();
313
- move(6 - activeDate.getDay());
319
+ moveCursor(6 - activeDate.getDay());
314
320
  break;
315
321
  case "PageUp":
316
322
  event.preventDefault();
317
- changeMonth(-1);
323
+ pageMonth(-1);
318
324
  break;
319
325
  case "PageDown":
320
326
  event.preventDefault();
321
- changeMonth(1);
327
+ pageMonth(1);
322
328
  break;
323
329
  case "Enter":
324
330
  case " ":
@@ -331,41 +337,74 @@ function Calendar({ mode, locale, visibleSeed, selected = null, range, min, max,
331
337
  }
332
338
 
333
339
  return (
334
- <div role="dialog" aria-modal="false" style={calendarStyle}>
335
- <div style={headerStyle}>
336
- <button type="button" data-terp="iconbutton" aria-label="Previous month" onClick={() => changeMonth(-1)} style={navButtonStyle}>‹</button>
337
- <div style={titleStyle}>{formatMonth(month, locale)}</div>
338
- <button type="button" data-terp="iconbutton" aria-label="Next month" onClick={() => changeMonth(1)} style={navButtonStyle}>›</button>
340
+ // Named by its month heading. role="dialog" with no accessible name announces itself as
341
+ // just "dialog", and the month was one level down on the grid — reached only after the
342
+ // dialog boundary had already been crossed unnamed. axe does not report this at the
343
+ // wcag2a/aa tags the lane runs, so opening the calendar in stage 4 did not surface it.
344
+ <div role="dialog" aria-modal="false" aria-labelledby={titleId} data-terp="calendar">
345
+ <div data-terp="calendar-header">
346
+ <button type="button" data-terp="iconbutton" aria-label="Previous month" onClick={() => changeMonth(-1)}>‹</button>
347
+ <div id={titleId} data-terp="calendar-title">{formatMonth(month, locale)}</div>
348
+ <button type="button" data-terp="iconbutton" aria-label="Next month" onClick={() => changeMonth(1)}>›</button>
339
349
  </div>
340
- <div style={weekStyle} aria-hidden="true">
341
- {weekdays.map((day) => <div key={day} style={weekdayStyle}>{day}</div>)}
350
+ <div data-terp="calendar-week" aria-hidden="true">
351
+ {weekdays.map((day) => <div key={day} data-terp="calendar-weekday">{day}</div>)}
342
352
  </div>
343
- <div id={gridId} role="grid" aria-label={formatMonth(month, locale)} style={weekStyle} onKeyDown={onGridKeyDown}>
344
- {days.map((day) => {
345
- const disabled = isDisabled(day, minDate, maxDate);
346
- const isSelected = mode === "single"
347
- ? sameDate(day, selected)
348
- : sameDate(day, range?.start) || sameDate(day, range?.end);
349
- const inRange = mode === "range" && isWithinRange(day, range);
350
- const active = sameDate(day, activeDate);
351
- return (
352
- <button
353
- key={toKey(day)}
354
- ref={active ? activeRef : undefined}
355
- type="button"
356
- role="gridcell"
357
- aria-selected={isSelected}
358
- aria-disabled={disabled}
359
- tabIndex={active ? 0 : -1}
360
- disabled={disabled}
361
- onClick={() => selectDate(day)}
362
- onFocus={() => setActiveDate(day)}
363
- style={{ ...dayStyle(isSelected, inRange, disabled), ...(day.getMonth() === month.getMonth() ? undefined : mutedDayStyle) }}
364
- >
365
- {day.getDate()}
366
- </button>
367
- );
368
- })}
353
+ {/* grid row gridcell. The 42 day buttons used to be DIRECT children of the
354
+ role="grid", which is an invalid ARIA grid: `grid` must own `row`s and a
355
+ `gridcell` must be owned by one, so axe rated it a critical violation on two
356
+ rules at once and no screen reader could report "week 3, Wednesday". Nothing
357
+ caught it because nothing in the repo opened a calendar — the resting-state
358
+ baselines have no picture of it and axe never reached the subtree.
359
+
360
+ The geometry is unchanged and that is why the rows can be real elements rather
361
+ than `display: contents`: the outer box keeps the row gap, each row keeps the
362
+ seven equal columns and the column gap, and both gaps are the same token they
363
+ always were, so 42 cells land on exactly the pixels they did as one flat grid. */}
364
+ <div
365
+ id={gridId}
366
+ role="grid"
367
+ aria-label={formatMonth(month, locale)}
368
+ data-terp="calendar-grid"
369
+ onKeyDown={onGridKeyDown}
370
+ >
371
+ {weeks.map((week) => (
372
+ <div key={toKey(week[0]!)} role="row" data-terp="calendar-week">
373
+ {week.map((day) => {
374
+ const disabled = isDisabled(day, minDate, maxDate);
375
+ const isSelected = mode === "single"
376
+ ? sameDate(day, selected)
377
+ : sameDate(day, range?.start) || sameDate(day, range?.end);
378
+ const inRange = mode === "range" && isWithinRange(day, range);
379
+ const active = sameDate(day, activeDate);
380
+ return (
381
+ <button
382
+ key={toKey(day)}
383
+ ref={active ? activeRef : undefined}
384
+ type="button"
385
+ role="gridcell"
386
+ aria-selected={isSelected}
387
+ aria-disabled={disabled}
388
+ tabIndex={active ? 0 : -1}
389
+ disabled={disabled}
390
+ onClick={() => selectDate(day)}
391
+ onFocus={() => setActiveDate(day)}
392
+ // The full date, because the visible text is a bare number and the weekday
393
+ // row is aria-hidden AND a sibling of the grid rather than columnheaders
394
+ // inside it — so a cell had no weekday, no month and no year to announce.
395
+ // aria-label is allowed on a gridcell, leaves the visible glyph alone, and is
396
+ // what every calendar in the APG's own examples effectively gives AT.
397
+ aria-label={formatFullDate(day, locale)}
398
+ data-terp="calendar-day"
399
+ data-in-range={inRange ? "true" : undefined}
400
+ data-outside-month={day.getMonth() === month.getMonth() ? undefined : "true"}
401
+ >
402
+ {day.getDate()}
403
+ </button>
404
+ );
405
+ })}
406
+ </div>
407
+ ))}
369
408
  </div>
370
409
  </div>
371
410
  );
@@ -379,6 +418,16 @@ function formatDate(date: Date, locale: string | undefined) {
379
418
  return new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric" }).format(date);
380
419
  }
381
420
 
421
+ /** The whole date, spoken: what a day cell announces, since its text is only a number. */
422
+ function formatFullDate(date: Date, locale: string | undefined) {
423
+ return new Intl.DateTimeFormat(locale, {
424
+ weekday: "long",
425
+ day: "numeric",
426
+ month: "long",
427
+ year: "numeric",
428
+ }).format(date);
429
+ }
430
+
382
431
  function formatMonth(date: Date, locale: string | undefined) {
383
432
  return new Intl.DateTimeFormat(locale, { year: "numeric", month: "long" }).format(date);
384
433
  }
@@ -408,10 +457,13 @@ function clampDate(date: Date, min: Date | null, max: Date | null) {
408
457
  if (max !== null && compareDate(date, max) > 0) return max;
409
458
  return date;
410
459
  }
411
- function monthGrid(month: Date) {
460
+ /** Six weeks of seven days covering `month`, the first cell being that month's Sunday-start. */
461
+ function monthWeeks(month: Date) {
412
462
  const first = startOfMonth(month);
413
463
  const start = addDays(first, -first.getDay());
414
- return Array.from({ length: 42 }, (_, index) => addDays(start, index));
464
+ return Array.from({ length: 6 }, (_, week) =>
465
+ Array.from({ length: 7 }, (_, day) => addDays(start, week * 7 + day)),
466
+ );
415
467
  }
416
468
  function isWithinRange(date: Date, range: DateRangeValue | undefined) {
417
469
  if (range?.start === null || range?.start === undefined || range.end === null || range.end === undefined) {
package/src/ui/Input.tsx CHANGED
@@ -1,30 +1,17 @@
1
- import type { CSSProperties, InputHTMLAttributes } from "react";
1
+ import type { InputHTMLAttributes } from "react";
2
2
 
3
3
  import { injectTerpStyles } from "../styles";
4
- import { CONTROL_TEXT_STYLE } from "./controlStyles";
5
4
 
6
5
  injectTerpStyles();
7
6
 
8
- const inputStyle: CSSProperties = {
9
- ...CONTROL_TEXT_STYLE,
10
- lineHeight: 1.2,
11
- minHeight: "2.25rem",
12
- padding: "0 var(--space-3)",
13
- border: "1px solid var(--color-neutral-300)",
14
- borderRadius: "var(--radius-md)",
15
- color: "var(--color-neutral-900)",
16
- background: "var(--color-neutral-0)",
17
- boxSizing: "border-box",
18
- };
19
-
20
7
  export type InputProps = InputHTMLAttributes<HTMLInputElement>;
21
8
 
22
9
  /**
23
10
  * Token-styled text input — use instead of a raw `<input>` (the module-boundary rule).
24
- * The `data-terp="input"` marker opts the element into the shared focus ring and
25
- * hover polish injected by react-core.
11
+ * The `data-terp="input"` marker is the whole styling hook: it carries the shared control
12
+ * surface, the focus ring, the hover border and the disabled treatment from the injected
13
+ * sheet, with the element type deciding the geometry (ADR 0094).
26
14
  */
27
- export function Input({ style, ...rest }: InputProps) {
28
- return <input data-terp="input" {...rest} style={{ ...inputStyle, ...style }} />;
15
+ export function Input(props: InputProps) {
16
+ return <input data-terp="input" {...props} />;
29
17
  }
30
-
@@ -29,4 +29,30 @@ describe("Markdown", () => {
29
29
  expect(screen.getByText(/<img src=x/)).toBeInTheDocument();
30
30
  expect(container.querySelector("img")).toBeNull();
31
31
  });
32
+ it("wraps its blocks in a boxless root, and renders nothing at all when empty", () => {
33
+ const { container } = render(
34
+ <Markdown
35
+ source={`# Title
36
+
37
+ Body paragraph.`}
38
+ />,
39
+ );
40
+ const root = container.querySelector('[data-terp="markdown"]');
41
+ expect(root).not.toBeNull();
42
+ // The wrapper exists so the component can be found and styled at all. It is boxless by
43
+ // rule — display: contents lives in the sheet — which is what keeps these blocks
44
+ // individual items of any parent that spaces its children with gap, so marking the
45
+ // component changed no consumer's layout.
46
+ expect(root!.children.length).toBe(2);
47
+ expect(root!.getAttribute("style")).toBeNull();
48
+
49
+ cleanup();
50
+ // An empty source still renders NOTHING, and that is load-bearing rather than tidy: the
51
+ // layout contract's runtime check reads the direct children of a governed body slot and
52
+ // refuses any marker its allow table does not name. An unconditional wrapper would turn
53
+ // an empty Markdown beside allowed components from a passing body into a fail-closed
54
+ // refusal — thrown for rendering no content.
55
+ const empty = render(<Markdown source=" " />);
56
+ expect(empty.container.firstChild).toBeNull();
57
+ });
32
58
  });
@@ -1,8 +1,25 @@
1
1
  import type { ReactNode } from "react";
2
2
 
3
+ import { injectTerpStyles } from "../styles";
3
4
  import { useUiText } from "../uiText";
4
5
  import type { UiText } from "../uiText";
5
6
 
7
+ // The sheet matters more here than anywhere else in the package, and it is worth being precise
8
+ // about why rather than overstating it. Every other component DEGRADES without its rules — a
9
+ // Stack falls back to a block and looks wrong. This one RESTRUCTURES: the wrapper below is only
10
+ // free because a rule makes it boxless, and as a plain block box it collapses however many
11
+ // prose blocks into a single flex or grid item of its parent.
12
+ //
13
+ // That said, this call is belt-and-braces rather than the thing standing between the two. The
14
+ // package publishes one entry point (exports is "." -> src/index.ts, which re-exports
15
+ // everything) and declares no `sideEffects` field, so importing anything from it loads every
16
+ // module and some two dozen of them inject. Twelve marker-rendering modules rely on exactly
17
+ // that and call nothing themselves. It is here because a module that owns a rule this load
18
+ // bearing should not depend on a sibling being imported, and because the guarantee is a
19
+ // packaging property nothing asserts — a future `sideEffects: false` plus tree-shaking would
20
+ // remove it silently.
21
+ injectTerpStyles();
22
+
6
23
  export interface MarkdownProps {
7
24
  source: UiText;
8
25
  }
@@ -177,8 +194,17 @@ function renderInline(text: string): ReactNode[] {
177
194
  export function Markdown({ source }: MarkdownProps) {
178
195
  const resolve = useUiText();
179
196
  const blocks = parseBlocks(resolve(source));
197
+ // An empty or whitespace-only source rendered literally nothing before this wrapper existed,
198
+ // and it still does. That is not tidiness: the layout contract's runtime check reads the
199
+ // DIRECT children of a governed body slot and refuses any whose marker is not in the slot's
200
+ // allow table, `markdown` included. So an unconditional wrapper would turn an empty Markdown
201
+ // beside allowed components from a passing body into a refused one — a fail-closed throw for
202
+ // rendering nothing.
203
+ if (blocks.length === 0) {
204
+ return null;
205
+ }
180
206
  return (
181
- <>
207
+ <div data-terp="markdown">
182
208
  {blocks.map((block, index) => {
183
209
  if (block.type === "heading") {
184
210
  const content = renderInline(block.text);
@@ -208,6 +234,6 @@ export function Markdown({ source }: MarkdownProps) {
208
234
  }
209
235
  return <p key={index}>{renderInline(block.text)}</p>;
210
236
  })}
211
- </>
237
+ </div>
212
238
  );
213
239
  }
@@ -21,10 +21,18 @@ describe("Menu", () => {
21
21
  fireEvent.click(screen.getByRole("button", { name: "Actions" }));
22
22
  const menu = screen.getByRole("menu");
23
23
  expect(screen.getByTestId("clip")).not.toContainElement(menu);
24
- expect(menu.parentElement).toHaveStyle({
25
- fontFamily: "var(--font-family-sans)",
26
- color: "var(--color-neutral-900)",
27
- });
24
+ const panel = menu.parentElement as HTMLElement;
25
+ expect(panel).toHaveAttribute("data-terp", "popover-panel");
26
+ // The panel's appearance is a sheet rule now (ADR 0094), so what it inlines is the whole
27
+ // assertion — and it can be the whole assertion, because nothing can inject panel styles
28
+ // any more: `panelStyle` went with UserMenu's migration. Exactly three declarations, the
29
+ // measured ones.
30
+ expect([...panel.style].sort()).toEqual(["left", "top", "visibility"]);
31
+ expect(panel.style.top).not.toBe("");
32
+ expect(panel.style.left).not.toBe("");
33
+ // And the owner attribute, which is the only handle a rule has on a panel this component
34
+ // portalled to document.body.
35
+ expect(panel).toHaveAttribute("data-owner", "popover");
28
36
  fireEvent.pointerDown(menu);
29
37
  expect(menu).toBeInTheDocument();
30
38
  });
@@ -82,4 +90,30 @@ describe("Menu", () => {
82
90
  expect(onOpenChange).toHaveBeenCalledTimes(1);
83
91
  expect(onOpenChange).toHaveBeenCalledWith(false);
84
92
  });
93
+ it("returns focus to the trigger on Tab, so the tab order continues after the button", () => {
94
+ render(
95
+ <Menu trigger="Open" triggerLabel="Actions">
96
+ {() => (
97
+ <>
98
+ <MenuItem label="Archive" onSelect={() => {}} />
99
+ <MenuItem label="Delete" onSelect={() => {}} />
100
+ </>
101
+ )}
102
+ </Menu>,
103
+ );
104
+ const trigger = screen.getByRole("button", { name: "Actions" });
105
+ fireEvent.click(trigger);
106
+ const menu = screen.getByRole("menu");
107
+ expect(menu.contains(document.activeElement)).toBe(true);
108
+
109
+ fireEvent.keyDown(menu, { key: "Tab" });
110
+
111
+ // The panel closes AND focus goes back to the trigger. Closing without restoring left focus
112
+ // on a tabIndex=-1 item inside a panel portalled to the end of document.body, which is then
113
+ // unmounted — so the browser's sequential-navigation starting point was a removed node at
114
+ // the wrong end of the document and Tab landed past all page content. The APG menu-button
115
+ // contract is that Tab moves to the next element after the BUTTON.
116
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
117
+ expect(document.activeElement).toBe(trigger);
118
+ });
85
119
  });