@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.
- package/README.md +6 -2
- package/package.json +2 -2
- package/src/AppShell.test.tsx +33 -12
- package/src/AppShell.tsx +69 -249
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/Breadcrumbs.test.tsx +24 -0
- package/src/Breadcrumbs.tsx +9 -32
- package/src/ConfirmDialog.tsx +13 -44
- package/src/EmptyState.tsx +8 -36
- package/src/ErrorState.tsx +8 -36
- package/src/Field.test.tsx +57 -0
- package/src/Field.tsx +46 -22
- package/src/HubPage.test.tsx +22 -13
- package/src/HubPage.tsx +25 -97
- package/src/LoadingState.tsx +3 -24
- package/src/LoginView.tsx +22 -75
- package/src/ModuleNav.test.tsx +19 -0
- package/src/ModuleNav.tsx +10 -35
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +15 -39
- package/src/PageActions.tsx +5 -10
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +8 -33
- package/src/ResourceList.tsx +13 -24
- package/src/UserMenu.test.tsx +12 -5
- package/src/UserMenu.tsx +33 -62
- package/src/admin/AuditLogAdmin.tsx +1 -10
- package/src/admin/GroupCreate.tsx +1 -1
- package/src/admin/GroupDetail.tsx +2 -2
- package/src/admin/UserCreate.tsx +1 -1
- package/src/admin/admin.test.tsx +31 -0
- package/src/dataview/DataView.test.tsx +109 -5
- package/src/dataview/DataView.tsx +41 -23
- package/src/dataview/DataViewCardList.tsx +14 -60
- package/src/dataview/DataViewColumnSettings.tsx +46 -51
- package/src/dataview/DataViewExpandableRow.tsx +2 -17
- package/src/dataview/DataViewPagination.tsx +2 -32
- package/src/dataview/DataViewRowActions.tsx +13 -33
- package/src/dataview/DataViewTable.tsx +16 -103
- package/src/dataview/DataViewToolbar.tsx +53 -76
- package/src/dataview/README.md +6 -0
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +4 -1
- package/src/dataview/types.ts +13 -0
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/feedback.test.tsx +26 -0
- package/src/files.test.tsx +18 -0
- package/src/files.tsx +15 -15
- package/src/icons.test.tsx +10 -6
- package/src/icons.tsx +9 -37
- package/src/index.ts +7 -4
- package/src/layout.test.tsx +24 -9
- package/src/layout.tsx +24 -21
- package/src/layoutContract.test.tsx +95 -0
- package/src/locale.tsx +24 -4
- package/src/markers.test.ts +354 -25
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +191 -1
- package/src/router.tsx +81 -18
- package/src/sso.test.tsx +6 -3
- package/src/ssr.test.tsx +1 -3
- package/src/styles.test.ts +855 -6
- package/src/styles.ts +3049 -153
- package/src/theme.tsx +24 -3
- package/src/toast.tsx +35 -71
- package/src/ui/Alert.test.tsx +12 -0
- package/src/ui/Alert.tsx +15 -43
- package/src/ui/Badge.test.tsx +14 -3
- package/src/ui/Badge.tsx +9 -28
- package/src/ui/Button.test.tsx +19 -4
- package/src/ui/Button.tsx +10 -63
- package/src/ui/Card.test.tsx +6 -2
- package/src/ui/Card.tsx +11 -39
- package/src/ui/Checkbox.tsx +2 -19
- package/src/ui/Combobox.test.tsx +22 -0
- package/src/ui/Combobox.tsx +31 -80
- package/src/ui/DatePicker.test.tsx +131 -4
- package/src/ui/DatePicker.tsx +158 -106
- package/src/ui/Input.tsx +6 -19
- package/src/ui/Markdown.test.tsx +26 -0
- package/src/ui/Markdown.tsx +28 -2
- package/src/ui/Menu.test.tsx +38 -4
- package/src/ui/Menu.tsx +50 -52
- package/src/ui/Popover.tsx +53 -19
- package/src/ui/Radio.tsx +5 -30
- package/src/ui/Select.tsx +7 -30
- package/src/ui/Switch.tsx +2 -20
- package/src/ui/Tabs.tsx +4 -28
- package/src/ui/Textarea.tsx +6 -17
- package/src/ui/Tooltip.tsx +9 -21
- package/src/ui/controlStyles.ts +0 -9
package/src/ui/DatePicker.tsx
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
2
|
-
import type {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
299
|
+
moveCursor(1);
|
|
294
300
|
break;
|
|
295
301
|
case "ArrowLeft":
|
|
296
302
|
event.preventDefault();
|
|
297
|
-
|
|
303
|
+
moveCursor(-1);
|
|
298
304
|
break;
|
|
299
305
|
case "ArrowDown":
|
|
300
306
|
event.preventDefault();
|
|
301
|
-
|
|
307
|
+
moveCursor(7);
|
|
302
308
|
break;
|
|
303
309
|
case "ArrowUp":
|
|
304
310
|
event.preventDefault();
|
|
305
|
-
|
|
311
|
+
moveCursor(-7);
|
|
306
312
|
break;
|
|
307
313
|
case "Home":
|
|
308
314
|
event.preventDefault();
|
|
309
|
-
|
|
315
|
+
moveCursor(-activeDate.getDay());
|
|
310
316
|
break;
|
|
311
317
|
case "End":
|
|
312
318
|
event.preventDefault();
|
|
313
|
-
|
|
319
|
+
moveCursor(6 - activeDate.getDay());
|
|
314
320
|
break;
|
|
315
321
|
case "PageUp":
|
|
316
322
|
event.preventDefault();
|
|
317
|
-
|
|
323
|
+
pageMonth(-1);
|
|
318
324
|
break;
|
|
319
325
|
case "PageDown":
|
|
320
326
|
event.preventDefault();
|
|
321
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
341
|
-
{weekdays.map((day) => <div key={day}
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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
|
|
25
|
-
* hover
|
|
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(
|
|
28
|
-
return <input data-terp="input" {...
|
|
15
|
+
export function Input(props: InputProps) {
|
|
16
|
+
return <input data-terp="input" {...props} />;
|
|
29
17
|
}
|
|
30
|
-
|
package/src/ui/Markdown.test.tsx
CHANGED
|
@@ -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
|
});
|
package/src/ui/Markdown.tsx
CHANGED
|
@@ -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
|
}
|
package/src/ui/Menu.test.tsx
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
});
|