@terpjs/react-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +190 -0
  2. package/package.json +44 -0
  3. package/src/AppShell.test.tsx +152 -0
  4. package/src/AppShell.tsx +554 -0
  5. package/src/Authorized.test.tsx +60 -0
  6. package/src/Authorized.tsx +21 -0
  7. package/src/Breadcrumbs.test.tsx +45 -0
  8. package/src/Breadcrumbs.tsx +110 -0
  9. package/src/ConfirmDialog.tsx +170 -0
  10. package/src/DetailPage.tsx +28 -0
  11. package/src/EmptyState.tsx +74 -0
  12. package/src/ErrorState.tsx +108 -0
  13. package/src/Field.test.tsx +53 -0
  14. package/src/Field.tsx +51 -0
  15. package/src/HubPage.test.tsx +108 -0
  16. package/src/HubPage.tsx +204 -0
  17. package/src/LoadingState.test.tsx +40 -0
  18. package/src/LoadingState.tsx +96 -0
  19. package/src/LoginView.test.tsx +57 -0
  20. package/src/LoginView.tsx +203 -0
  21. package/src/ModuleNav.test.tsx +96 -0
  22. package/src/ModuleNav.tsx +88 -0
  23. package/src/OverviewPage.tsx +26 -0
  24. package/src/Page.test.tsx +147 -0
  25. package/src/Page.tsx +158 -0
  26. package/src/PageActions.test.tsx +104 -0
  27. package/src/PageActions.tsx +72 -0
  28. package/src/ProfileView.test.tsx +112 -0
  29. package/src/ProfileView.tsx +84 -0
  30. package/src/RequireAuth.test.tsx +89 -0
  31. package/src/RequireAuth.tsx +22 -0
  32. package/src/ResourceList.test.tsx +176 -0
  33. package/src/ResourceList.tsx +123 -0
  34. package/src/TerpProvider.tsx +320 -0
  35. package/src/UserMenu.test.tsx +166 -0
  36. package/src/UserMenu.tsx +125 -0
  37. package/src/admin/AdminHub.tsx +108 -0
  38. package/src/admin/AuditLogAdmin.tsx +116 -0
  39. package/src/admin/GroupCreate.tsx +90 -0
  40. package/src/admin/GroupDetail.tsx +446 -0
  41. package/src/admin/GroupsAdmin.tsx +109 -0
  42. package/src/admin/UserCreate.tsx +115 -0
  43. package/src/admin/UserDetail.tsx +228 -0
  44. package/src/admin/UsersAdmin.tsx +111 -0
  45. package/src/admin/admin.test.tsx +537 -0
  46. package/src/admin/crumbs.tsx +14 -0
  47. package/src/admin/module.tsx +51 -0
  48. package/src/admin/roles.ts +19 -0
  49. package/src/bootstrap.test.tsx +67 -0
  50. package/src/bootstrap.tsx +270 -0
  51. package/src/capabilities.test.ts +24 -0
  52. package/src/capabilities.ts +26 -0
  53. package/src/createAuthClient.test.ts +176 -0
  54. package/src/createAuthClient.ts +105 -0
  55. package/src/dataview/DataView.test.tsx +392 -0
  56. package/src/dataview/DataView.tsx +467 -0
  57. package/src/dataview/DataViewCardList.tsx +189 -0
  58. package/src/dataview/DataViewColumnSettings.tsx +118 -0
  59. package/src/dataview/DataViewExpandableRow.tsx +67 -0
  60. package/src/dataview/DataViewPagination.tsx +113 -0
  61. package/src/dataview/DataViewRowActions.tsx +131 -0
  62. package/src/dataview/DataViewTable.tsx +359 -0
  63. package/src/dataview/DataViewToolbar.tsx +260 -0
  64. package/src/dataview/README.md +138 -0
  65. package/src/dataview/glyphs.tsx +175 -0
  66. package/src/dataview/hooks/hooks.test.tsx +240 -0
  67. package/src/dataview/hooks/useDataViewQuery.ts +72 -0
  68. package/src/dataview/hooks/useDataViewState.ts +310 -0
  69. package/src/dataview/hooks/useServerDataView.ts +154 -0
  70. package/src/dataview/hooks/useViewSearch.ts +68 -0
  71. package/src/dataview/index.ts +62 -0
  72. package/src/dataview/internal.tsx +96 -0
  73. package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
  74. package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
  75. package/src/dataview/repositories/repositories.test.ts +158 -0
  76. package/src/dataview/repositories/viewState.test.ts +90 -0
  77. package/src/dataview/repositories/viewState.ts +128 -0
  78. package/src/dataview/types.ts +249 -0
  79. package/src/errorMessages.test.tsx +83 -0
  80. package/src/errorMessages.tsx +79 -0
  81. package/src/feedback.test.tsx +167 -0
  82. package/src/files.test.tsx +142 -0
  83. package/src/files.tsx +174 -0
  84. package/src/icons.test.tsx +46 -0
  85. package/src/icons.tsx +533 -0
  86. package/src/index.ts +155 -0
  87. package/src/layout.test.tsx +72 -0
  88. package/src/layout.tsx +90 -0
  89. package/src/layoutContract.test.tsx +179 -0
  90. package/src/layoutContract.ts +137 -0
  91. package/src/locale.test.tsx +97 -0
  92. package/src/locale.tsx +246 -0
  93. package/src/nav.test.ts +21 -0
  94. package/src/nav.ts +13 -0
  95. package/src/pageMarker.ts +15 -0
  96. package/src/raw.d.ts +7 -0
  97. package/src/realtime-hook.test.tsx +226 -0
  98. package/src/realtime.test.ts +44 -0
  99. package/src/realtime.ts +307 -0
  100. package/src/refresh-session.test.tsx +114 -0
  101. package/src/revocation.test.tsx +81 -0
  102. package/src/router.test.tsx +307 -0
  103. package/src/router.tsx +222 -0
  104. package/src/sso.test.tsx +128 -0
  105. package/src/sso.ts +142 -0
  106. package/src/ssr.test.tsx +45 -0
  107. package/src/styles.test.ts +21 -0
  108. package/src/styles.ts +302 -0
  109. package/src/theme.test.tsx +74 -0
  110. package/src/theme.tsx +143 -0
  111. package/src/toast.test.tsx +94 -0
  112. package/src/toast.tsx +214 -0
  113. package/src/tokens.guard.test.ts +51 -0
  114. package/src/ui/Alert.test.tsx +19 -0
  115. package/src/ui/Alert.tsx +115 -0
  116. package/src/ui/Badge.test.tsx +14 -0
  117. package/src/ui/Badge.tsx +48 -0
  118. package/src/ui/Button.test.tsx +36 -0
  119. package/src/ui/Button.tsx +95 -0
  120. package/src/ui/Card.test.tsx +40 -0
  121. package/src/ui/Card.tsx +92 -0
  122. package/src/ui/Checkbox.test.tsx +17 -0
  123. package/src/ui/Checkbox.tsx +51 -0
  124. package/src/ui/Combobox.test.tsx +58 -0
  125. package/src/ui/Combobox.tsx +313 -0
  126. package/src/ui/DatePicker.test.tsx +60 -0
  127. package/src/ui/DatePicker.tsx +421 -0
  128. package/src/ui/Input.tsx +30 -0
  129. package/src/ui/Markdown.test.tsx +32 -0
  130. package/src/ui/Markdown.tsx +213 -0
  131. package/src/ui/Menu.test.tsx +85 -0
  132. package/src/ui/Menu.tsx +216 -0
  133. package/src/ui/Popover.tsx +218 -0
  134. package/src/ui/Radio.test.tsx +29 -0
  135. package/src/ui/Radio.tsx +127 -0
  136. package/src/ui/Select.tsx +40 -0
  137. package/src/ui/Switch.test.tsx +17 -0
  138. package/src/ui/Switch.tsx +53 -0
  139. package/src/ui/Tabs.test.tsx +29 -0
  140. package/src/ui/Tabs.tsx +128 -0
  141. package/src/ui/Textarea.tsx +27 -0
  142. package/src/ui/Tooltip.test.tsx +28 -0
  143. package/src/ui/Tooltip.tsx +67 -0
  144. package/src/ui/controlStyles.ts +9 -0
  145. package/src/uiText.test.tsx +93 -0
  146. package/src/uiText.tsx +342 -0
  147. package/src/unwrap.test.ts +67 -0
  148. package/src/unwrap.ts +101 -0
  149. package/src/useResource.test.tsx +118 -0
  150. package/src/useResource.ts +110 -0
  151. package/src/useTerpClient.test.ts +35 -0
  152. package/tsconfig.json +17 -0
  153. package/vite.config.ts +14 -0
  154. package/vitest.setup.ts +58 -0
@@ -0,0 +1,421 @@
1
+ import { useEffect, useId, useMemo, useRef, useState } from "react";
2
+ import type { CSSProperties, KeyboardEvent } from "react";
3
+
4
+ import { useLocale } from "../locale";
5
+ import { injectTerpStyles } from "../styles";
6
+ import { useUiText } from "../uiText";
7
+ import type { UiText } from "../uiText";
8
+ import { CONTROL_TEXT_STYLE } from "./controlStyles";
9
+ import { Popover } from "./Popover";
10
+
11
+ injectTerpStyles();
12
+
13
+ export interface DateRangeValue {
14
+ start: Date | null;
15
+ end: Date | null;
16
+ }
17
+
18
+ export interface DatePickerProps {
19
+ value?: Date | null;
20
+ defaultValue?: Date | null;
21
+ onChange?: (value: Date | null) => void;
22
+ min?: Date;
23
+ max?: Date;
24
+ disabled?: boolean;
25
+ placeholder?: UiText;
26
+ "aria-label"?: string;
27
+ "aria-invalid"?: boolean | "true" | "false";
28
+ }
29
+
30
+ export interface DateRangePickerProps {
31
+ value?: DateRangeValue;
32
+ defaultValue?: DateRangeValue;
33
+ onChange?: (value: DateRangeValue) => void;
34
+ min?: Date;
35
+ max?: Date;
36
+ disabled?: boolean;
37
+ placeholder?: UiText;
38
+ "aria-label"?: string;
39
+ "aria-invalid"?: boolean | "true" | "false";
40
+ }
41
+
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
+ /** Single-date calendar picker with locale-aware labels and keyboard navigation. */
96
+ export function DatePicker({
97
+ value,
98
+ defaultValue = null,
99
+ onChange,
100
+ min,
101
+ max,
102
+ disabled = false,
103
+ placeholder = "Select date",
104
+ "aria-label": ariaLabel,
105
+ "aria-invalid": ariaInvalid,
106
+ }: DatePickerProps) {
107
+ const [uncontrolledValue, setUncontrolledValue] = useState<Date | null>(defaultValue);
108
+ const selected = normalizeDate(value ?? uncontrolledValue);
109
+ const [open, setOpen] = useState(false);
110
+ const locale = useDateLocale();
111
+ const resolve = useUiText();
112
+ const formatted = selected === null ? resolve(placeholder) : formatDate(selected, locale);
113
+
114
+ function commit(next: Date) {
115
+ if (value === undefined) {
116
+ setUncontrolledValue(next);
117
+ }
118
+ onChange?.(next);
119
+ setOpen(false);
120
+ }
121
+
122
+ return (
123
+ <Popover
124
+ open={open}
125
+ onOpenChange={(next) => !disabled && setOpen(next)}
126
+ align="start"
127
+ trigger={
128
+ <button
129
+ type="button"
130
+ data-terp="input"
131
+ aria-haspopup="dialog"
132
+ aria-label={ariaLabel}
133
+ aria-invalid={ariaInvalid}
134
+ disabled={disabled}
135
+ style={{ ...triggerStyle, color: selected === null ? "var(--color-neutral-500)" : triggerStyle.color }}
136
+ >
137
+ <span>{formatted}</span>
138
+ <span aria-hidden="true">📅</span>
139
+ </button>
140
+ }
141
+ >
142
+ {({ close }) => (
143
+ <Calendar
144
+ mode="single"
145
+ locale={locale}
146
+ selected={selected}
147
+ visibleSeed={selected ?? new Date()}
148
+ min={min}
149
+ max={max}
150
+ onSelect={commit}
151
+ onEscape={() => close(true)}
152
+ />
153
+ )}
154
+ </Popover>
155
+ );
156
+ }
157
+
158
+ /** Range calendar picker with start/end selection, min/max bounds and locale-aware labels. */
159
+ export function DateRangePicker({
160
+ value,
161
+ defaultValue = { start: null, end: null },
162
+ onChange,
163
+ min,
164
+ max,
165
+ disabled = false,
166
+ placeholder = "Select date range",
167
+ "aria-label": ariaLabel,
168
+ "aria-invalid": ariaInvalid,
169
+ }: DateRangePickerProps) {
170
+ const [uncontrolledValue, setUncontrolledValue] = useState<DateRangeValue>(defaultValue);
171
+ const selected = normalizeRange(value ?? uncontrolledValue);
172
+ const [open, setOpen] = useState(false);
173
+ const locale = useDateLocale();
174
+ const resolve = useUiText();
175
+ const formatted = selected.start === null
176
+ ? resolve(placeholder)
177
+ : selected.end === null
178
+ ? `${formatDate(selected.start, locale)} –`
179
+ : `${formatDate(selected.start, locale)} – ${formatDate(selected.end, locale)}`;
180
+
181
+ function commit(next: DateRangeValue) {
182
+ if (value === undefined) {
183
+ setUncontrolledValue(next);
184
+ }
185
+ onChange?.(next);
186
+ if (next.start !== null && next.end !== null) {
187
+ setOpen(false);
188
+ }
189
+ }
190
+
191
+ return (
192
+ <Popover
193
+ open={open}
194
+ onOpenChange={(next) => !disabled && setOpen(next)}
195
+ align="start"
196
+ trigger={
197
+ <button
198
+ type="button"
199
+ data-terp="input"
200
+ aria-haspopup="dialog"
201
+ aria-label={ariaLabel}
202
+ aria-invalid={ariaInvalid}
203
+ disabled={disabled}
204
+ style={{ ...triggerStyle, color: selected.start === null ? "var(--color-neutral-500)" : triggerStyle.color }}
205
+ >
206
+ <span>{formatted}</span>
207
+ <span aria-hidden="true">📅</span>
208
+ </button>
209
+ }
210
+ >
211
+ {({ close }) => (
212
+ <Calendar
213
+ mode="range"
214
+ locale={locale}
215
+ range={selected}
216
+ visibleSeed={selected.start ?? new Date()}
217
+ min={min}
218
+ max={max}
219
+ onRangeSelect={commit}
220
+ onEscape={() => close(true)}
221
+ />
222
+ )}
223
+ </Popover>
224
+ );
225
+ }
226
+
227
+ interface CalendarProps {
228
+ mode: "single" | "range";
229
+ locale: string | undefined;
230
+ visibleSeed: Date;
231
+ selected?: Date | null;
232
+ range?: DateRangeValue;
233
+ min?: Date;
234
+ max?: Date;
235
+ onSelect?: (date: Date) => void;
236
+ onRangeSelect?: (range: DateRangeValue) => void;
237
+ onEscape: () => void;
238
+ }
239
+
240
+ function Calendar({ mode, locale, visibleSeed, selected = null, range, min, max, onSelect, onRangeSelect, onEscape }: CalendarProps) {
241
+ const gridId = useId();
242
+ const minDate = normalizeDate(min);
243
+ const maxDate = normalizeDate(max);
244
+ const initial = clampDate(normalizeDate(visibleSeed) ?? today(), minDate, maxDate);
245
+ const [month, setMonth] = useState(() => startOfMonth(initial));
246
+ const [activeDate, setActiveDate] = useState(initial);
247
+ const activeRef = useRef<HTMLButtonElement>(null);
248
+ const days = useMemo(() => monthGrid(month), [month]);
249
+ const weekdays = useMemo(() => weekdayNames(locale), [locale]);
250
+
251
+ useEffect(() => {
252
+ window.setTimeout(() => activeRef.current?.focus(), 0);
253
+ }, []);
254
+
255
+ function move(daysDelta: number) {
256
+ const next = clampDate(addDays(activeDate, daysDelta), minDate, maxDate);
257
+ setActiveDate(next);
258
+ if (next.getMonth() !== month.getMonth() || next.getFullYear() !== month.getFullYear()) {
259
+ setMonth(startOfMonth(next));
260
+ }
261
+ }
262
+
263
+ function changeMonth(delta: number) {
264
+ const nextMonth = addMonths(month, delta);
265
+ setMonth(nextMonth);
266
+ setActiveDate(clampDate(new Date(nextMonth.getFullYear(), nextMonth.getMonth(), Math.min(activeDate.getDate(), daysInMonth(nextMonth))), minDate, maxDate));
267
+ }
268
+
269
+ function selectDate(day: Date) {
270
+ if (isDisabled(day, minDate, maxDate)) {
271
+ return;
272
+ }
273
+ if (mode === "single") {
274
+ onSelect?.(day);
275
+ return;
276
+ }
277
+ const current = range ?? { start: null, end: null };
278
+ if (current.start === null || current.end !== null || compareDate(day, current.start) < 0) {
279
+ onRangeSelect?.({ start: day, end: null });
280
+ } else {
281
+ onRangeSelect?.({ start: current.start, end: day });
282
+ }
283
+ }
284
+
285
+ function onGridKeyDown(event: KeyboardEvent<HTMLDivElement>) {
286
+ switch (event.key) {
287
+ case "Escape":
288
+ event.preventDefault();
289
+ onEscape();
290
+ break;
291
+ case "ArrowRight":
292
+ event.preventDefault();
293
+ move(1);
294
+ break;
295
+ case "ArrowLeft":
296
+ event.preventDefault();
297
+ move(-1);
298
+ break;
299
+ case "ArrowDown":
300
+ event.preventDefault();
301
+ move(7);
302
+ break;
303
+ case "ArrowUp":
304
+ event.preventDefault();
305
+ move(-7);
306
+ break;
307
+ case "Home":
308
+ event.preventDefault();
309
+ move(-activeDate.getDay());
310
+ break;
311
+ case "End":
312
+ event.preventDefault();
313
+ move(6 - activeDate.getDay());
314
+ break;
315
+ case "PageUp":
316
+ event.preventDefault();
317
+ changeMonth(-1);
318
+ break;
319
+ case "PageDown":
320
+ event.preventDefault();
321
+ changeMonth(1);
322
+ break;
323
+ case "Enter":
324
+ case " ":
325
+ event.preventDefault();
326
+ selectDate(activeDate);
327
+ break;
328
+ default:
329
+ break;
330
+ }
331
+ }
332
+
333
+ 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>
339
+ </div>
340
+ <div style={weekStyle} aria-hidden="true">
341
+ {weekdays.map((day) => <div key={day} style={weekdayStyle}>{day}</div>)}
342
+ </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
+ })}
369
+ </div>
370
+ </div>
371
+ );
372
+ }
373
+
374
+ function useDateLocale() {
375
+ return useLocale()?.locale;
376
+ }
377
+
378
+ function formatDate(date: Date, locale: string | undefined) {
379
+ return new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric" }).format(date);
380
+ }
381
+
382
+ function formatMonth(date: Date, locale: string | undefined) {
383
+ return new Intl.DateTimeFormat(locale, { year: "numeric", month: "long" }).format(date);
384
+ }
385
+
386
+ function weekdayNames(locale: string | undefined) {
387
+ const base = new Date(2024, 0, 7);
388
+ return Array.from({ length: 7 }, (_, index) => new Intl.DateTimeFormat(locale, { weekday: "short" }).format(addDays(base, index)));
389
+ }
390
+
391
+ function normalizeRange(value: DateRangeValue): DateRangeValue {
392
+ return { start: normalizeDate(value.start), end: normalizeDate(value.end) };
393
+ }
394
+ function normalizeDate(date: Date | null | undefined): Date | null {
395
+ return date instanceof Date && !Number.isNaN(date.getTime()) ? new Date(date.getFullYear(), date.getMonth(), date.getDate()) : null;
396
+ }
397
+ function today() { return normalizeDate(new Date())!; }
398
+ function startOfMonth(date: Date) { return new Date(date.getFullYear(), date.getMonth(), 1); }
399
+ function addDays(date: Date, days: number) { return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days); }
400
+ function addMonths(date: Date, months: number) { return new Date(date.getFullYear(), date.getMonth() + months, 1); }
401
+ function daysInMonth(date: Date) { return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); }
402
+ function toKey(date: Date) { return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; }
403
+ function sameDate(a: Date | null | undefined, b: Date | null | undefined) { return a !== null && a !== undefined && b !== null && b !== undefined && compareDate(a, b) === 0; }
404
+ function compareDate(a: Date, b: Date) { return a.getTime() - b.getTime(); }
405
+ function isDisabled(date: Date, min: Date | null, max: Date | null) { return (min !== null && compareDate(date, min) < 0) || (max !== null && compareDate(date, max) > 0); }
406
+ function clampDate(date: Date, min: Date | null, max: Date | null) {
407
+ if (min !== null && compareDate(date, min) < 0) return min;
408
+ if (max !== null && compareDate(date, max) > 0) return max;
409
+ return date;
410
+ }
411
+ function monthGrid(month: Date) {
412
+ const first = startOfMonth(month);
413
+ const start = addDays(first, -first.getDay());
414
+ return Array.from({ length: 42 }, (_, index) => addDays(start, index));
415
+ }
416
+ function isWithinRange(date: Date, range: DateRangeValue | undefined) {
417
+ if (range?.start === null || range?.start === undefined || range.end === null || range.end === undefined) {
418
+ return false;
419
+ }
420
+ return compareDate(date, range.start) >= 0 && compareDate(date, range.end) <= 0;
421
+ }
@@ -0,0 +1,30 @@
1
+ import type { CSSProperties, InputHTMLAttributes } from "react";
2
+
3
+ import { injectTerpStyles } from "../styles";
4
+ import { CONTROL_TEXT_STYLE } from "./controlStyles";
5
+
6
+ injectTerpStyles();
7
+
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
+ export type InputProps = InputHTMLAttributes<HTMLInputElement>;
21
+
22
+ /**
23
+ * 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.
26
+ */
27
+ export function Input({ style, ...rest }: InputProps) {
28
+ return <input data-terp="input" {...rest} style={{ ...inputStyle, ...style }} />;
29
+ }
30
+
@@ -0,0 +1,32 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { Markdown } from "./Markdown";
6
+
7
+ afterEach(cleanup);
8
+
9
+ describe("Markdown", () => {
10
+ it("renders common markdown blocks and inline formatting", () => {
11
+ render(
12
+ <Markdown
13
+ source={`# Title\n\nA **bold** and *em* paragraph with \`code\`.\n\n- One\n- Two\n\n1. First\n2. Second\n\n\`\`\`\nconst ok = true;\n\`\`\``}
14
+ />,
15
+ );
16
+ expect(screen.getByRole("heading", { name: "Title" })).toBeInTheDocument();
17
+ expect(screen.getByText("bold").tagName).toBe("STRONG");
18
+ expect(screen.getByText("em").tagName).toBe("EM");
19
+ expect(screen.getByText("const ok = true;")).toBeInTheDocument();
20
+ expect(screen.getAllByRole("list")).toHaveLength(2);
21
+ });
22
+
23
+ it("allows only safe links and renders raw html as text", () => {
24
+ const { container } = render(
25
+ <Markdown source={'[safe](records/1) [bad](javascript:alert(1)) <img src=x onerror=alert(1)>'} />,
26
+ );
27
+ expect(screen.getByRole("link", { name: "safe" })).toHaveAttribute("href", "records/1");
28
+ expect(screen.queryByRole("link", { name: "bad" })).not.toBeInTheDocument();
29
+ expect(screen.getByText(/<img src=x/)).toBeInTheDocument();
30
+ expect(container.querySelector("img")).toBeNull();
31
+ });
32
+ });
@@ -0,0 +1,213 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { useUiText } from "../uiText";
4
+ import type { UiText } from "../uiText";
5
+
6
+ export interface MarkdownProps {
7
+ source: UiText;
8
+ }
9
+
10
+ type Block =
11
+ | { type: "heading"; level: 1 | 2 | 3 | 4 | 5 | 6; text: string }
12
+ | { type: "paragraph"; text: string }
13
+ | { type: "code"; text: string }
14
+ | { type: "list"; ordered: boolean; items: string[] };
15
+
16
+ const SAFE_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
17
+
18
+ function isSafeLink(href: string): boolean {
19
+ return /^(https?:\/\/|mailto:)/i.test(href) || (!SAFE_SCHEME_RE.test(href) && !href.startsWith("//"));
20
+ }
21
+
22
+ function parseBlocks(source: string): Block[] {
23
+ const lines = source.replace(/\r\n/g, "\n").split("\n");
24
+ const blocks: Block[] = [];
25
+ let index = 0;
26
+
27
+ while (index < lines.length) {
28
+ const line = lines[index] ?? "";
29
+ if (line.trim() === "") {
30
+ index += 1;
31
+ continue;
32
+ }
33
+ if (line.trimStart().startsWith("```")) {
34
+ const code: string[] = [];
35
+ index += 1;
36
+ while (index < lines.length && !(lines[index] ?? "").trimStart().startsWith("```")) {
37
+ code.push(lines[index] ?? "");
38
+ index += 1;
39
+ }
40
+ if (index < lines.length) {
41
+ index += 1;
42
+ }
43
+ blocks.push({ type: "code", text: code.join("\n") });
44
+ continue;
45
+ }
46
+ const heading = /^(#{1,6})\s+(.+)$/.exec(line);
47
+ if (heading) {
48
+ blocks.push({ type: "heading", level: heading[1].length as 1 | 2 | 3 | 4 | 5 | 6, text: heading[2] });
49
+ index += 1;
50
+ continue;
51
+ }
52
+ const unordered = /^\s*[-*]\s+(.+)$/.exec(line);
53
+ const ordered = /^\s*\d+[.)]\s+(.+)$/.exec(line);
54
+ if (unordered || ordered) {
55
+ const listItems: string[] = [];
56
+ const isOrdered = Boolean(ordered);
57
+ while (index < lines.length) {
58
+ const match = isOrdered ? /^\s*\d+[.)]\s+(.+)$/.exec(lines[index] ?? "") : /^\s*[-*]\s+(.+)$/.exec(lines[index] ?? "");
59
+ if (!match) {
60
+ break;
61
+ }
62
+ listItems.push(match[1]);
63
+ index += 1;
64
+ }
65
+ blocks.push({ type: "list", ordered: isOrdered, items: listItems });
66
+ continue;
67
+ }
68
+ const paragraph: string[] = [line.trim()];
69
+ index += 1;
70
+ while (index < lines.length) {
71
+ const next = lines[index] ?? "";
72
+ if (
73
+ next.trim() === "" ||
74
+ next.trimStart().startsWith("```") ||
75
+ /^(#{1,6})\s+/.test(next) ||
76
+ /^\s*[-*]\s+/.test(next) ||
77
+ /^\s*\d+[.)]\s+/.test(next)
78
+ ) {
79
+ break;
80
+ }
81
+ paragraph.push(next.trim());
82
+ index += 1;
83
+ }
84
+ blocks.push({ type: "paragraph", text: paragraph.join(" ") });
85
+ }
86
+
87
+ return blocks;
88
+ }
89
+
90
+ function findNextToken(text: string, start: number) {
91
+ const tokens = ["`", "**", "*", "_", "["];
92
+ let next = -1;
93
+ let token = "";
94
+ for (const candidate of tokens) {
95
+ const found = text.indexOf(candidate, start);
96
+ if (found !== -1 && (next === -1 || found < next)) {
97
+ next = found;
98
+ token = candidate;
99
+ }
100
+ }
101
+ return { next, token };
102
+ }
103
+
104
+ function renderInline(text: string): ReactNode[] {
105
+ const nodes: ReactNode[] = [];
106
+ let index = 0;
107
+ let key = 0;
108
+
109
+ while (index < text.length) {
110
+ const { next, token } = findNextToken(text, index);
111
+ if (next === -1) {
112
+ nodes.push(text.slice(index));
113
+ break;
114
+ }
115
+ if (next > index) {
116
+ nodes.push(text.slice(index, next));
117
+ }
118
+ if (token === "`") {
119
+ const end = text.indexOf("`", next + 1);
120
+ if (end === -1) {
121
+ nodes.push("`");
122
+ index = next + 1;
123
+ } else {
124
+ nodes.push(<code key={key++}>{text.slice(next + 1, end)}</code>);
125
+ index = end + 1;
126
+ }
127
+ continue;
128
+ }
129
+ if (token === "**") {
130
+ const end = text.indexOf("**", next + 2);
131
+ if (end === -1) {
132
+ nodes.push("**");
133
+ index = next + 2;
134
+ } else {
135
+ nodes.push(<strong key={key++}>{renderInline(text.slice(next + 2, end))}</strong>);
136
+ index = end + 2;
137
+ }
138
+ continue;
139
+ }
140
+ if (token === "*" || token === "_") {
141
+ const end = text.indexOf(token, next + 1);
142
+ if (end === -1) {
143
+ nodes.push(token);
144
+ index = next + 1;
145
+ } else {
146
+ nodes.push(<em key={key++}>{renderInline(text.slice(next + 1, end))}</em>);
147
+ index = end + 1;
148
+ }
149
+ continue;
150
+ }
151
+ const closeLabel = text.indexOf("]", next + 1);
152
+ const openHref = closeLabel === -1 ? -1 : text.indexOf("(", closeLabel + 1);
153
+ const closeHref = openHref === -1 ? -1 : text.indexOf(")", openHref + 1);
154
+ if (closeLabel !== -1 && openHref === closeLabel + 1 && closeHref !== -1) {
155
+ const href = text.slice(openHref + 1, closeHref).trim();
156
+ const label = text.slice(next + 1, closeLabel);
157
+ if (isSafeLink(href)) {
158
+ nodes.push(
159
+ <a key={key++} href={href}>
160
+ {renderInline(label)}
161
+ </a>,
162
+ );
163
+ } else {
164
+ nodes.push(`${label} (${href})`);
165
+ }
166
+ index = closeHref + 1;
167
+ } else {
168
+ nodes.push("[");
169
+ index = next + 1;
170
+ }
171
+ }
172
+
173
+ return nodes;
174
+ }
175
+
176
+ /** Safe dependency-free markdown renderer; raw HTML is always rendered as text. */
177
+ export function Markdown({ source }: MarkdownProps) {
178
+ const resolve = useUiText();
179
+ const blocks = parseBlocks(resolve(source));
180
+ return (
181
+ <>
182
+ {blocks.map((block, index) => {
183
+ if (block.type === "heading") {
184
+ const content = renderInline(block.text);
185
+ if (block.level === 1) return <h1 key={index}>{content}</h1>;
186
+ if (block.level === 2) return <h2 key={index}>{content}</h2>;
187
+ if (block.level === 3) return <h3 key={index}>{content}</h3>;
188
+ if (block.level === 4) return <h4 key={index}>{content}</h4>;
189
+ if (block.level === 5) return <h5 key={index}>{content}</h5>;
190
+ return <h6 key={index}>{content}</h6>;
191
+ }
192
+ if (block.type === "code") {
193
+ return (
194
+ <pre key={index}>
195
+ <code>{block.text}</code>
196
+ </pre>
197
+ );
198
+ }
199
+ if (block.type === "list") {
200
+ const List = block.ordered ? "ol" : "ul";
201
+ return (
202
+ <List key={index}>
203
+ {block.items.map((item, itemIndex) => (
204
+ <li key={itemIndex}>{renderInline(item)}</li>
205
+ ))}
206
+ </List>
207
+ );
208
+ }
209
+ return <p key={index}>{renderInline(block.text)}</p>;
210
+ })}
211
+ </>
212
+ );
213
+ }