@terpjs/react-core 0.9.0 → 0.11.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 (101) hide show
  1. package/README.md +57 -21
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +323 -4
  4. package/src/AppShell.tsx +401 -66
  5. package/src/EmptyState.test.tsx +30 -0
  6. package/src/EmptyState.tsx +23 -3
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.test.tsx +34 -2
  11. package/src/LoginView.tsx +43 -18
  12. package/src/ModuleNav.test.tsx +17 -10
  13. package/src/ModuleNav.tsx +35 -3
  14. package/src/Page.tsx +23 -1
  15. package/src/ProfileView.test.tsx +1 -1
  16. package/src/ProfileView.tsx +2 -4
  17. package/src/SettingsPage.tsx +50 -0
  18. package/src/SplitPage.tsx +150 -0
  19. package/src/UserMenu.test.tsx +28 -5
  20. package/src/UserMenu.tsx +15 -9
  21. package/src/admin/AuditLogAdmin.tsx +21 -7
  22. package/src/admin/GroupCreate.tsx +17 -3
  23. package/src/admin/GroupDetail.tsx +48 -13
  24. package/src/admin/GroupsAdmin.tsx +13 -5
  25. package/src/admin/UserCreate.tsx +40 -11
  26. package/src/admin/UserDetail.tsx +4 -1
  27. package/src/admin/UsersAdmin.tsx +14 -6
  28. package/src/admin/admin.test.tsx +212 -8
  29. package/src/admin/fieldErrors.ts +45 -0
  30. package/src/bootstrap.test.tsx +208 -0
  31. package/src/bootstrap.tsx +121 -5
  32. package/src/breakpoints.ts +41 -0
  33. package/src/dataview/DataView.tsx +12 -5
  34. package/src/dataview/DataViewCardList.tsx +8 -7
  35. package/src/dataview/DataViewPagination.tsx +15 -8
  36. package/src/dataview/DataViewTable.tsx +32 -21
  37. package/src/dataview/README.md +13 -2
  38. package/src/dataview/index.ts +1 -0
  39. package/src/dataview/internal.tsx +31 -1
  40. package/src/dataview/types.ts +26 -3
  41. package/src/format.test.tsx +213 -0
  42. package/src/format.ts +150 -0
  43. package/src/icons.tsx +67 -5
  44. package/src/index.ts +56 -6
  45. package/src/layout.manifest.json +118 -0
  46. package/src/layout.manifest.test.ts +205 -0
  47. package/src/layout.test.tsx +198 -1
  48. package/src/layout.tsx +208 -11
  49. package/src/layoutContract.test.tsx +311 -2
  50. package/src/layoutContract.ts +44 -3
  51. package/src/layoutDeclaration.test.ts +435 -0
  52. package/src/layoutDeclaration.ts +531 -0
  53. package/src/locale.tsx +12 -0
  54. package/src/markers.test.ts +27 -5
  55. package/src/nav.test.ts +234 -4
  56. package/src/nav.ts +180 -6
  57. package/src/navActive.test.ts +115 -0
  58. package/src/navActive.ts +119 -0
  59. package/src/navLink.tsx +20 -2
  60. package/src/previewBridge.test.ts +327 -0
  61. package/src/previewBridge.ts +278 -0
  62. package/src/raw.d.ts +14 -2
  63. package/src/review.test.tsx +272 -0
  64. package/src/router.test.tsx +575 -2
  65. package/src/router.tsx +212 -19
  66. package/src/styles.test.ts +535 -58
  67. package/src/styles.ts +1130 -111
  68. package/src/theme.test.tsx +29 -0
  69. package/src/theme.themes.test.ts +13 -7
  70. package/src/theme.tsx +30 -33
  71. package/src/themes.ts +54 -0
  72. package/src/toast.tsx +2 -1
  73. package/src/tokens.guard.test.ts +239 -0
  74. package/src/typography.test.tsx +213 -0
  75. package/src/typography.tsx +255 -0
  76. package/src/ui/Avatar.test.tsx +63 -0
  77. package/src/ui/Avatar.tsx +65 -0
  78. package/src/ui/Button.test.tsx +69 -3
  79. package/src/ui/Button.tsx +57 -4
  80. package/src/ui/Card.test.tsx +13 -0
  81. package/src/ui/Card.tsx +28 -1
  82. package/src/ui/Checkbox.tsx +10 -2
  83. package/src/ui/Combobox.test.tsx +139 -0
  84. package/src/ui/Combobox.tsx +255 -43
  85. package/src/ui/DatePicker.tsx +44 -12
  86. package/src/ui/Input.test.tsx +123 -0
  87. package/src/ui/Input.tsx +65 -2
  88. package/src/ui/Menu.tsx +16 -5
  89. package/src/ui/Popover.tsx +13 -0
  90. package/src/ui/Radio.tsx +10 -5
  91. package/src/ui/Select.test.tsx +232 -0
  92. package/src/ui/Select.tsx +177 -8
  93. package/src/ui/Switch.tsx +10 -2
  94. package/src/ui/Tabs.test.tsx +28 -0
  95. package/src/ui/Tabs.tsx +30 -6
  96. package/src/ui/Tooltip.test.tsx +56 -1
  97. package/src/ui/Tooltip.tsx +69 -6
  98. package/src/uiText.literals.test.ts +199 -0
  99. package/src/uiText.tsx +36 -0
  100. package/src/unwrap.test.ts +132 -0
  101. package/src/unwrap.ts +118 -32
@@ -2,7 +2,7 @@ import { useEffect, useId, useMemo, useRef, useState } from "react";
2
2
  import type { InputHTMLAttributes, KeyboardEvent } from "react";
3
3
 
4
4
  import { injectTerpStyles } from "../styles";
5
- import { useUiText } from "../uiText";
5
+ import { useStrings, useUiText } from "../uiText";
6
6
  import type { UiText } from "../uiText";
7
7
 
8
8
  injectTerpStyles();
@@ -13,12 +13,9 @@ export interface ComboboxOption {
13
13
  disabled?: boolean;
14
14
  }
15
15
 
16
- export interface ComboboxProps
16
+ interface ComboboxCommonProps
17
17
  extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange" | "children" | "role"> {
18
18
  options: readonly ComboboxOption[];
19
- value?: string | null;
20
- defaultValue?: string | null;
21
- onChange?: (value: string | null, option: ComboboxOption | null) => void;
22
19
  loading?: boolean;
23
20
  loadingText?: UiText;
24
21
  noOptionsText?: UiText;
@@ -33,15 +30,181 @@ export interface ComboboxProps
33
30
  defaultOpen?: boolean;
34
31
  }
35
32
 
36
- /** Filterable ARIA combobox/typeahead with controlled or uncontrolled single selection. */
37
- export function Combobox({
33
+ /** One selection, or none. */
34
+ export interface ComboboxSingleProps extends ComboboxCommonProps {
35
+ multiple?: false;
36
+ value?: string | null;
37
+ defaultValue?: string | null;
38
+ onChange?: (value: string | null, option: ComboboxOption | null) => void;
39
+ }
40
+
41
+ /** A SET of selections, rendered as removable tokens. */
42
+ export interface ComboboxMultipleProps extends ComboboxCommonProps {
43
+ multiple: true;
44
+ value?: readonly string[];
45
+ defaultValue?: readonly string[];
46
+ onChange?: (values: readonly string[], options: readonly ComboboxOption[]) => void;
47
+ /** Accessible name for a token's remove control; the option's label is appended. */
48
+ removeLabel?: UiText;
49
+ }
50
+
51
+ /**
52
+ * A mode rather than a second component, and the union is the point: `multiple` decides the
53
+ * shape of `value`, `defaultValue` and `onChange` together, so handing a plain string to a
54
+ * multiple combobox — or an array to a single one — is a typecheck error rather than a
55
+ * runtime surprise. Same reasoning as `ICON_NAMES`: a mistake that the compiler can hold is
56
+ * not worth discovering in a browser.
57
+ */
58
+ export type ComboboxProps = ComboboxSingleProps | ComboboxMultipleProps;
59
+
60
+ function isMultiple(props: ComboboxProps): props is ComboboxMultipleProps {
61
+ return props.multiple === true;
62
+ }
63
+
64
+ /**
65
+ * Filterable ARIA combobox/typeahead, single or multiple.
66
+ *
67
+ * **Why `multiple` is here rather than in a component of its own.** A set-valued field had no
68
+ * sanctioned control at all, and the absence did not stop anyone: it produced comma-separated
69
+ * text boxes with the legal values listed in a grey hint beside them — a closed enum typed as
70
+ * free text, so the validation the value set could have enforced was simply lost. That is
71
+ * ADR 0096's principle rather than a preference: a seam that does not cover the common case is
72
+ * a hole, because the compliant path is unavailable and code goes around it.
73
+ *
74
+ * It is a mode because the hard parts already exist here. The listbox, the filtering, the
75
+ * active-option model, the outside-click close and the whole `aria-activedescendant` wiring
76
+ * are the same; what differs is that a selection is a set, that choosing one keeps the list
77
+ * open, and that the selections need somewhere to live. A second component would have had to
78
+ * re-derive all of the first list and would drift from it.
79
+ */
80
+ export function Combobox(props: ComboboxProps) {
81
+ return isMultiple(props) ? <MultiCombobox {...props} /> : <SingleCombobox {...props} />;
82
+ }
83
+
84
+ function MultiCombobox(props: ComboboxMultipleProps) {
85
+ const { value, defaultValue, onChange, removeLabel, ...rest } = props;
86
+ const resolve = useUiText();
87
+ const strings = useStrings();
88
+ const [uncontrolled, setUncontrolled] = useState<readonly string[]>(defaultValue ?? []);
89
+ const selected = value ?? uncontrolled;
90
+ const byValue = useMemo(
91
+ () => new Map(props.options.map((option) => [option.value, option])),
92
+ [props.options],
93
+ );
94
+ // Order follows the SELECTION, not the option list: a token row that reorders itself when a
95
+ // later option is picked moves the target a user was about to click.
96
+ const selectedOptions = selected.flatMap((v) => {
97
+ const option = byValue.get(v);
98
+ return option === undefined ? [] : [option];
99
+ });
100
+
101
+ function commitValues(next: readonly string[]) {
102
+ if (value === undefined) {
103
+ setUncontrolled(next);
104
+ }
105
+ onChange?.(
106
+ next,
107
+ next.flatMap((v) => {
108
+ const option = byValue.get(v);
109
+ return option === undefined ? [] : [option];
110
+ }),
111
+ );
112
+ }
113
+
114
+ function toggle(option: ComboboxOption) {
115
+ commitValues(
116
+ selected.includes(option.value)
117
+ ? selected.filter((v) => v !== option.value)
118
+ : [...selected, option.value],
119
+ );
120
+ }
121
+
122
+ return (
123
+ <ComboboxShell
124
+ {...rest}
125
+ multiple
126
+ selectedValues={selected}
127
+ onSelectOption={toggle}
128
+ onClearSelection={() => commitValues([])}
129
+ tokens={selectedOptions.map((option) => (
130
+ <span key={option.value} data-terp="combobox-token">
131
+ {resolve(option.label)}
132
+ <button
133
+ type="button"
134
+ data-terp="combobox-token-remove"
135
+ // The label carries the option, so a screen reader hears which token this
136
+ // removes rather than one of N identical "Remove" buttons.
137
+ aria-label={`${resolve(removeLabel ?? strings.comboboxRemove)} ${resolve(option.label)}`}
138
+ disabled={rest.disabled}
139
+ onClick={() => commitValues(selected.filter((v) => v !== option.value))}
140
+ >
141
+ ×
142
+ </button>
143
+ </span>
144
+ ))}
145
+ onRemoveLast={() => {
146
+ const last = selected.at(-1);
147
+ if (last !== undefined) {
148
+ commitValues(selected.slice(0, -1));
149
+ }
150
+ }}
151
+ />
152
+ );
153
+ }
154
+
155
+ function SingleCombobox({ multiple: _multiple, ...props }: ComboboxSingleProps) {
156
+ const { value, defaultValue = null, onChange, ...rest } = props;
157
+ const resolve = useUiText();
158
+ const [uncontrolled, setUncontrolled] = useState<string | null>(defaultValue);
159
+ const selectedValue = value ?? uncontrolled;
160
+ const selectedOption = props.options.find((option) => option.value === selectedValue) ?? null;
161
+
162
+ return (
163
+ <ComboboxShell
164
+ {...rest}
165
+ multiple={false}
166
+ selectedValues={selectedValue === null ? [] : [selectedValue]}
167
+ // The input mirrors the selection's label in single mode, which is the whole
168
+ // difference in how the text box behaves between the two.
169
+ mirroredLabel={selectedOption === null ? "" : resolve(selectedOption.label)}
170
+ onSelectOption={(option) => {
171
+ if (value === undefined) {
172
+ setUncontrolled(option.value);
173
+ }
174
+ onChange?.(option.value, option);
175
+ }}
176
+ onClearSelection={() => {
177
+ if (value === undefined) {
178
+ setUncontrolled(null);
179
+ }
180
+ onChange?.(null, null);
181
+ }}
182
+ />
183
+ );
184
+ }
185
+
186
+ interface ShellProps extends ComboboxCommonProps {
187
+ multiple: boolean;
188
+ selectedValues: readonly string[];
189
+ onSelectOption: (option: ComboboxOption) => void;
190
+ onClearSelection: () => void;
191
+ mirroredLabel?: string;
192
+ tokens?: readonly React.ReactNode[];
193
+ onRemoveLast?: () => void;
194
+ }
195
+
196
+ function ComboboxShell({
38
197
  options,
39
- value,
40
- defaultValue = null,
41
- onChange,
198
+ multiple,
199
+ selectedValues,
200
+ onSelectOption,
201
+ onClearSelection,
202
+ mirroredLabel = "",
203
+ tokens,
204
+ onRemoveLast,
42
205
  loading = false,
43
- loadingText = "Loading…",
44
- noOptionsText = "No options",
206
+ loadingText,
207
+ noOptionsText,
45
208
  clearable = false,
46
209
  defaultOpen = false,
47
210
  disabled,
@@ -51,18 +214,17 @@ export function Combobox({
51
214
  placeholder,
52
215
  style,
53
216
  ...rest
54
- }: ComboboxProps) {
217
+ }: ShellProps) {
55
218
  const resolve = useUiText();
219
+ const strings = useStrings();
56
220
  const baseId = useId();
57
221
  const rootRef = useRef<HTMLDivElement>(null);
58
222
  const inputRef = useRef<HTMLInputElement>(null);
59
- const [uncontrolledValue, setUncontrolledValue] = useState<string | null>(defaultValue);
60
- const selectedValue = value ?? uncontrolledValue;
61
- const selectedOption = options.find((option) => option.value === selectedValue) ?? null;
62
- const [query, setQuery] = useState(() => (selectedOption ? resolve(selectedOption.label) : ""));
223
+ const chosen = useMemo(() => new Set(selectedValues), [selectedValues]);
224
+ const [query, setQuery] = useState(mirroredLabel);
63
225
  const [open, setOpen] = useState(defaultOpen);
64
226
  const [activeValue, setActiveValue] = useState<string | null>(
65
- defaultOpen ? selectedOption?.value ?? null : null,
227
+ defaultOpen ? selectedValues[0] ?? null : null,
66
228
  );
67
229
 
68
230
  // What the DOM should say, as opposed to what the state happens to hold. The listbox render
@@ -72,18 +234,32 @@ export function Combobox({
72
234
  const isOpen = open && disabled !== true;
73
235
 
74
236
  const renderedOptions = useMemo(() => {
75
- const normalized = query.trim().toLocaleLowerCase();
76
- if (normalized.length === 0 || selectedOption !== null && query === resolve(selectedOption.label)) {
237
+ // `toLowerCase`, not the locale-aware fold: this decides whether a substring MATCHES, and a
238
+ // match is not a presentation question for the host to answer. Folded against a Turkish host,
239
+ // `Item` becomes `ıtem` — dotless — which does not contain the `i` the user typed, so every
240
+ // option with a capital I disappears for a Turkish visitor and for nobody else.
241
+ // Folding both sides with the same host locale does not rescue it: the needle comes from a
242
+ // keyboard and the haystack from a server, and the two agree only when the fold is invariant.
243
+ const normalized = query.trim().toLowerCase();
244
+ // The second clause is single-mode only: there the box MIRRORS the chosen label, so the
245
+ // text equalling that label means "nothing typed yet" rather than a filter. In multiple
246
+ // mode the box is only ever a filter — there is no one label to mirror — so a query that
247
+ // happens to equal an option's label must still filter to it.
248
+ if (normalized.length === 0 || (!multiple && mirroredLabel !== "" && query === mirroredLabel)) {
77
249
  return options;
78
250
  }
79
- return options.filter((option) => resolve(option.label).toLocaleLowerCase().includes(normalized));
80
- }, [options, query, resolve, selectedOption]);
251
+ return options.filter((option) => resolve(option.label).toLowerCase().includes(normalized));
252
+ }, [multiple, mirroredLabel, options, query, resolve]);
81
253
  const enabledOptions = renderedOptions.filter((option) => !option.disabled);
82
254
  const activeOption = renderedOptions.find((option) => option.value === activeValue) ?? enabledOptions[0] ?? null;
83
255
 
84
256
  useEffect(() => {
85
- setQuery(selectedOption ? resolve(selectedOption.label) : "");
86
- }, [resolve, selectedOption]);
257
+ // Only single mode mirrors: in multiple mode this would erase what the user is typing
258
+ // every time a token changes, which is exactly when they are mid-search for the next one.
259
+ if (!multiple) {
260
+ setQuery(mirroredLabel);
261
+ }
262
+ }, [multiple, mirroredLabel]);
87
263
 
88
264
  useEffect(() => {
89
265
  if (!open) {
@@ -92,7 +268,7 @@ export function Combobox({
92
268
  function onPointerDown(event: PointerEvent | MouseEvent) {
93
269
  if (rootRef.current !== null && event.target instanceof Node && !rootRef.current.contains(event.target)) {
94
270
  setOpen(false);
95
- setQuery(selectedOption ? resolve(selectedOption.label) : "");
271
+ setQuery(multiple ? "" : mirroredLabel);
96
272
  }
97
273
  }
98
274
  document.addEventListener("pointerdown", onPointerDown);
@@ -101,19 +277,37 @@ export function Combobox({
101
277
  document.removeEventListener("pointerdown", onPointerDown);
102
278
  document.removeEventListener("mousedown", onPointerDown);
103
279
  };
104
- }, [open, resolve, selectedOption]);
280
+ }, [multiple, mirroredLabel, open]);
105
281
 
106
282
  function commit(option: ComboboxOption | null) {
107
283
  if (option?.disabled) {
108
284
  return;
109
285
  }
110
- if (value === undefined) {
111
- setUncontrolledValue(option?.value ?? null);
286
+ if (option === null) {
287
+ onClearSelection();
288
+ setQuery("");
289
+ setOpen(false);
290
+ setActiveValue(null);
291
+ return;
112
292
  }
113
- setQuery(option ? (value === undefined ? resolve(option.label) : selectedOption ? resolve(selectedOption.label) : "") : "");
293
+ onSelectOption(option);
294
+ if (multiple) {
295
+ // The list STAYS OPEN and the filter is cleared: picking one member of a set is
296
+ // almost never the last thing the user wants, and closing after each pick makes
297
+ // choosing three options three round trips through the control.
298
+ setQuery("");
299
+ setActiveValue(option.value);
300
+ return;
301
+ }
302
+ // `mirroredLabel`, never the clicked option's label. In a CONTROLLED combobox the
303
+ // parent may decline the change — `value` stays what it was — and the box has to keep
304
+ // showing what the prop says rather than what was clicked. Uncontrolled reaches the
305
+ // same place one render later: the selection changes, `mirroredLabel` changes with it,
306
+ // and the mirror effect above sets the box. So this line is correct in both modes for
307
+ // the same reason, which the previous three-way conditional was doing by hand.
308
+ setQuery(mirroredLabel);
114
309
  setOpen(false);
115
- setActiveValue(option?.value ?? null);
116
- onChange?.(option?.value ?? null, option);
310
+ setActiveValue(option.value);
117
311
  }
118
312
 
119
313
  function moveActive(direction: 1 | -1 | "first" | "last") {
@@ -169,7 +363,16 @@ export function Combobox({
169
363
  if (open) {
170
364
  event.preventDefault();
171
365
  setOpen(false);
172
- setQuery(selectedOption ? resolve(selectedOption.label) : "");
366
+ setQuery(multiple ? "" : mirroredLabel);
367
+ }
368
+ break;
369
+ case "Backspace":
370
+ // Only with an empty box, so this never eats a character. It is the shortcut every
371
+ // token field has, and it is an ADDITION to the per-token remove buttons rather than
372
+ // a replacement: a keyboard user who does not know the shortcut can still tab to a
373
+ // token and press it.
374
+ if (multiple && query.length === 0) {
375
+ onRemoveLast?.();
173
376
  }
174
377
  break;
175
378
  default:
@@ -179,7 +382,8 @@ export function Combobox({
179
382
 
180
383
  return (
181
384
  <div ref={rootRef} data-terp="combobox">
182
- <div data-terp="combobox-field">
385
+ <div data-terp="combobox-field" data-multiple={multiple ? "true" : undefined}>
386
+ {tokens}
183
387
  <input
184
388
  {...rest}
185
389
  ref={inputRef}
@@ -197,7 +401,9 @@ export function Combobox({
197
401
  onFocus?.(event);
198
402
  if (!disabled) {
199
403
  setOpen(true);
200
- setActiveValue(selectedOption?.value ?? enabledOptions[0]?.value ?? null);
404
+ setActiveValue(
405
+ (multiple ? null : selectedValues[0] ?? null) ?? enabledOptions[0]?.value ?? null,
406
+ );
201
407
  }
202
408
  }}
203
409
  onBlur={onBlur}
@@ -205,18 +411,15 @@ export function Combobox({
205
411
  setQuery(event.currentTarget.value);
206
412
  setOpen(true);
207
413
  setActiveValue(null);
208
- if (selectedValue !== null && value === undefined) {
209
- setUncontrolledValue(null);
210
- }
211
414
  }}
212
415
  onKeyDown={handleKeyDown}
213
416
  style={style}
214
417
  />
215
- {clearable && !disabled && query.length > 0 && (
418
+ {clearable && !disabled && (query.length > 0 || (multiple && chosen.size > 0)) && (
216
419
  <button
217
420
  type="button"
218
421
  data-terp="iconbutton"
219
- aria-label="Clear selection"
422
+ aria-label={multiple ? strings.clearAllSelections : strings.clearSelection}
220
423
  onClick={() => {
221
424
  commit(null);
222
425
  inputRef.current?.focus();
@@ -227,16 +430,25 @@ export function Combobox({
227
430
  )}
228
431
  </div>
229
432
  {isOpen && (
230
- <div id={`${baseId}-listbox`} role="listbox" data-terp="combobox-list">
433
+ <div
434
+ id={`${baseId}-listbox`}
435
+ role="listbox"
436
+ aria-multiselectable={multiple ? true : undefined}
437
+ data-terp="combobox-list"
438
+ >
231
439
  {loading ? (
232
- <div role="status" data-terp="combobox-empty">{resolve(loadingText)}</div>
440
+ <div role="status" data-terp="combobox-empty">
441
+ {resolve(loadingText ?? strings.comboboxLoading)}
442
+ </div>
233
443
  ) : renderedOptions.length === 0 ? (
234
- <div data-terp="combobox-empty">{resolve(noOptionsText)}</div>
444
+ <div data-terp="combobox-empty">
445
+ {resolve(noOptionsText ?? strings.comboboxNoOptions)}
446
+ </div>
235
447
  ) : (
236
448
  renderedOptions.map((option) => {
237
449
  const label = resolve(option.label);
238
450
  const active = option.value === activeOption?.value;
239
- const selected = option.value === selectedValue;
451
+ const selected = chosen.has(option.value);
240
452
  return (
241
453
  <button
242
454
  key={option.value}
@@ -1,9 +1,10 @@
1
1
  import { useEffect, useId, useMemo, useRef, useState } from "react";
2
2
  import type { KeyboardEvent } from "react";
3
3
 
4
+ import { formatDate } from "../format";
4
5
  import { useLocale } from "../locale";
5
6
  import { injectTerpStyles } from "../styles";
6
- import { useUiText } from "../uiText";
7
+ import { useStrings, useUiText } from "../uiText";
7
8
  import type { UiText } from "../uiText";
8
9
  import { Popover } from "./Popover";
9
10
 
@@ -50,7 +51,7 @@ export function DatePicker({
50
51
  min,
51
52
  max,
52
53
  disabled = false,
53
- placeholder = "Select date",
54
+ placeholder,
54
55
  "aria-label": ariaLabel,
55
56
  "aria-invalid": ariaInvalid,
56
57
  defaultOpen = false,
@@ -65,8 +66,15 @@ export function DatePicker({
65
66
  // was clicking the disabled trigger.
66
67
  const [open, setOpen] = useState(defaultOpen && !disabled);
67
68
  const locale = useDateLocale();
69
+ const strings = useStrings();
68
70
  const resolve = useUiText();
69
- const formatted = selected === null ? resolve(placeholder) : formatDate(selected, locale);
71
+ // Falls back to the string TABLE rather than to a literal default on the prop. A
72
+ // `placeholder = "Select date"` default is overridable and still untranslatable: a plain
73
+ // string resolves as-is, so an app that does not pass the prop shows English in every
74
+ // locale. Reaching the table means the app's own catalogue answers when the caller says
75
+ // nothing, which is the whole point of having one.
76
+ const formatted =
77
+ selected === null ? resolve(placeholder ?? strings.selectDate) : formatDate(selected, locale);
70
78
 
71
79
  function commit(next: Date) {
72
80
  if (value === undefined) {
@@ -120,7 +128,7 @@ export function DateRangePicker({
120
128
  min,
121
129
  max,
122
130
  disabled = false,
123
- placeholder = "Select date range",
131
+ placeholder,
124
132
  "aria-label": ariaLabel,
125
133
  "aria-invalid": ariaInvalid,
126
134
  defaultOpen = false,
@@ -135,9 +143,10 @@ export function DateRangePicker({
135
143
  // was clicking the disabled trigger.
136
144
  const [open, setOpen] = useState(defaultOpen && !disabled);
137
145
  const locale = useDateLocale();
146
+ const strings = useStrings();
138
147
  const resolve = useUiText();
139
148
  const formatted = selected.start === null
140
- ? resolve(placeholder)
149
+ ? resolve(placeholder ?? strings.selectDateRange)
141
150
  : selected.end === null
142
151
  ? `${formatDate(selected.start, locale)} –`
143
152
  : `${formatDate(selected.start, locale)} – ${formatDate(selected.end, locale)}`;
@@ -202,6 +211,7 @@ interface CalendarProps {
202
211
  }
203
212
 
204
213
  function Calendar({ mode, locale, visibleSeed, selected = null, range, min, max, onSelect, onRangeSelect, onEscape }: CalendarProps) {
214
+ const strings = useStrings();
205
215
  const gridId = useId();
206
216
  const titleId = useId();
207
217
  const minDate = normalizeDate(min);
@@ -343,9 +353,9 @@ function Calendar({ mode, locale, visibleSeed, selected = null, range, min, max,
343
353
  // wcag2a/aa tags the lane runs, so opening the calendar in stage 4 did not surface it.
344
354
  <div role="dialog" aria-modal="false" aria-labelledby={titleId} data-terp="calendar">
345
355
  <div data-terp="calendar-header">
346
- <button type="button" data-terp="iconbutton" aria-label="Previous month" onClick={() => changeMonth(-1)}>‹</button>
356
+ <button type="button" data-terp="iconbutton" aria-label={strings.previousMonth} onClick={() => changeMonth(-1)}>‹</button>
347
357
  <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>
358
+ <button type="button" data-terp="iconbutton" aria-label={strings.nextMonth} onClick={() => changeMonth(1)}>›</button>
349
359
  </div>
350
360
  <div data-terp="calendar-week" aria-hidden="true">
351
361
  {weekdays.map((day) => <div key={day} data-terp="calendar-weekday">{day}</div>)}
@@ -414,13 +424,34 @@ function useDateLocale() {
414
424
  return useLocale()?.locale;
415
425
  }
416
426
 
417
- function formatDate(date: Date, locale: string | undefined) {
418
- return new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric" }).format(date);
427
+ // `formatDate` used to live here, three functions deep in a file about calendars, and it was the
428
+ // only general-purpose locale-correct date rendering in the package. It is `../format` now and
429
+ // imported back; the three below stay, because a spoken day, a grid caption and a column header
430
+ // are calendar parts rather than general formatting.
431
+ //
432
+ // They are cached for the reason `../format` caches its own: constructing an Intl formatter costs
433
+ // far more than using one — measured at roughly 55x on this repository's Node — and the spoken-day
434
+ // formatter runs once per DAY CELL, so an open calendar built 42 of them per render and a further
435
+ // seven for the column headers.
436
+ const CALENDAR_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
437
+
438
+ function calendarFormatter(
439
+ locale: string | undefined,
440
+ kind: "full" | "month" | "weekday",
441
+ options: Intl.DateTimeFormatOptions,
442
+ ): Intl.DateTimeFormat {
443
+ const key = `${kind}|${locale ?? ""}`;
444
+ let formatter = CALENDAR_FORMATTERS.get(key);
445
+ if (formatter === undefined) {
446
+ formatter = new Intl.DateTimeFormat(locale, options);
447
+ CALENDAR_FORMATTERS.set(key, formatter);
448
+ }
449
+ return formatter;
419
450
  }
420
451
 
421
452
  /** The whole date, spoken: what a day cell announces, since its text is only a number. */
422
453
  function formatFullDate(date: Date, locale: string | undefined) {
423
- return new Intl.DateTimeFormat(locale, {
454
+ return calendarFormatter(locale, "full", {
424
455
  weekday: "long",
425
456
  day: "numeric",
426
457
  month: "long",
@@ -429,12 +460,13 @@ function formatFullDate(date: Date, locale: string | undefined) {
429
460
  }
430
461
 
431
462
  function formatMonth(date: Date, locale: string | undefined) {
432
- return new Intl.DateTimeFormat(locale, { year: "numeric", month: "long" }).format(date);
463
+ return calendarFormatter(locale, "month", { year: "numeric", month: "long" }).format(date);
433
464
  }
434
465
 
435
466
  function weekdayNames(locale: string | undefined) {
436
467
  const base = new Date(2024, 0, 7);
437
- return Array.from({ length: 7 }, (_, index) => new Intl.DateTimeFormat(locale, { weekday: "short" }).format(addDays(base, index)));
468
+ const formatter = calendarFormatter(locale, "weekday", { weekday: "short" });
469
+ return Array.from({ length: 7 }, (_, index) => formatter.format(addDays(base, index)));
438
470
  }
439
471
 
440
472
  function normalizeRange(value: DateRangeValue): DateRangeValue {
@@ -0,0 +1,123 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { Field } from "../Field";
6
+ import { Input } from "./Input";
7
+
8
+ afterEach(cleanup);
9
+
10
+ describe("Input", () => {
11
+ it("renders a bare input for every type but password", () => {
12
+ // The wrapper exists for exactly one type. Two child selectors in the sheet reach for
13
+ // `data-terp="input"` as a DIRECT child — the toolbar search and the resource-list create
14
+ // field — and both would break against a wrapper, so "only password wraps" is a fact those
15
+ // rules depend on rather than an implementation detail.
16
+ const { container, rerender } = render(<Input type="text" defaultValue="" />);
17
+ expect(container.firstElementChild?.tagName).toBe("INPUT");
18
+ rerender(<Input type="search" defaultValue="" />);
19
+ expect(container.firstElementChild?.tagName).toBe("INPUT");
20
+ rerender(<Input defaultValue="" />);
21
+ expect(container.firstElementChild?.tagName).toBe("INPUT");
22
+ rerender(<Input type="password" defaultValue="" />);
23
+ expect(container.firstElementChild?.getAttribute("data-terp")).toBe("input-password");
24
+ });
25
+
26
+ it("reveals and re-hides the value, and its NAME is what says which it will do", () => {
27
+ // One encoding of the state, not two. The name swaps, and there is deliberately no
28
+ // `aria-pressed`: a toggle announced as "Hide password, pressed" claims the value is hidden
29
+ // and shown at once. It also keeps this button outside the sheet's shared hover guard, which
30
+ // excludes `[aria-pressed="true"]` and would leave the revealed toggle with no hover at all.
31
+ render(<Input type="password" aria-label="Password" defaultValue="hunter2" />);
32
+ const input = screen.getByLabelText("Password");
33
+ expect(input).toHaveAttribute("type", "password");
34
+ const toggle = screen.getByRole("button", { name: "Show password" });
35
+ expect(toggle).not.toHaveAttribute("aria-pressed");
36
+ fireEvent.click(toggle);
37
+ expect(input).toHaveAttribute("type", "text");
38
+ const now = screen.getByRole("button", { name: "Hide password" });
39
+ expect(now).not.toHaveAttribute("aria-pressed");
40
+ fireEvent.click(now);
41
+ expect(input).toHaveAttribute("type", "password");
42
+ });
43
+
44
+ it("does not submit the form it sits in", () => {
45
+ // A button inside a form defaults to type="submit". A reveal toggle that posted the form
46
+ // would be a data-loss bug reachable by one click, and no visual lane could see it.
47
+ render(<Input type="password" aria-label="Password" defaultValue="" />);
48
+ expect(screen.getByRole("button", { name: "Show password" })).toHaveAttribute("type", "button");
49
+ });
50
+
51
+ it("keeps Field's aria on the input, not on the wrapper", () => {
52
+ // The one that would have shipped silently. `Field` clones its control to inject
53
+ // `aria-describedby` and `aria-invalid`, and the sheet's invalid border is
54
+ // `input[data-terp="input"][aria-invalid="true"]` — a single-element selector. If the spread
55
+ // landed on the wrapper span, the attribute would sit on one element and the marker on
56
+ // another, the selector would match neither, and every password field with a hint or an error
57
+ // would quietly lose its red border. There is one such field in the example app today.
58
+ render(
59
+ <Field label="Password" hint="At least 16 characters" error="Too short">
60
+ <Input type="password" defaultValue="" />
61
+ </Field>,
62
+ );
63
+ const input = screen.getByLabelText("Password");
64
+ expect(input.tagName).toBe("INPUT");
65
+ expect(input).toHaveAttribute("aria-invalid", "true");
66
+ expect(input).toHaveAttribute("data-terp", "input");
67
+ const described = (input.getAttribute("aria-describedby") ?? "").split(" ").filter(Boolean);
68
+ expect(described).toHaveLength(2);
69
+ // And the wrapper carries none of it.
70
+ const wrapper = input.parentElement!;
71
+ expect(wrapper).toHaveAttribute("data-terp", "input-password");
72
+ expect(wrapper).not.toHaveAttribute("aria-invalid");
73
+ expect(wrapper).not.toHaveAttribute("aria-describedby");
74
+ });
75
+
76
+ it("is named by the label TEXT, so the toggle's own name cannot join it", () => {
77
+ // `Field` wraps the control in a `<label>`, so the toggle is a label descendant, and a label
78
+ // takes its name from everything inside it. Chromium duly computed "Password Show password"
79
+ // for this input until `Field` started pointing `aria-labelledby` at the label's text span.
80
+ //
81
+ // WHAT THIS TEST CAN AND CANNOT SEE, because the first version of it was worthless: jsdom's
82
+ // accessible-name implementation does NOT walk into a descendant's `aria-label`, so
83
+ // `toHaveAccessibleName("Password")` passed here while the real browser disagreed. It asserted
84
+ // the deviation, not the fix. So the wiring is what is pinned here — the attribute exists and
85
+ // resolves to the label text — and the computed NAME is asserted in a real engine, in
86
+ // apps/workbench/visual/computed.spec.ts against the admin-user-create specimen.
87
+ render(
88
+ <Field label="Password">
89
+ <Input type="password" defaultValue="" />
90
+ </Field>,
91
+ );
92
+ const input = screen.getByLabelText("Password");
93
+ expect(input.tagName).toBe("INPUT");
94
+ const labelledBy = input.getAttribute("aria-labelledby");
95
+ expect(labelledBy).not.toBeNull();
96
+ expect(document.getElementById(labelledBy!)?.textContent).toBe("Password");
97
+ // The toggle keeps its own name; asserting it were unlabelled would be the wrong fix.
98
+ expect(screen.getByRole("button", { name: "Show password" })).toBeInTheDocument();
99
+ });
100
+
101
+ it("disables its toggle when the field itself is disabled or read-only", () => {
102
+ // A field the caller switched off is switched off as a whole. Otherwise the value stays
103
+ // unreachable while the control beside it still reveals it.
104
+ const { rerender } = render(<Input type="password" aria-label="Password" disabled />);
105
+ expect(screen.getByRole("button", { name: "Show password" })).toBeDisabled();
106
+ rerender(<Input type="password" aria-label="Password" readOnly />);
107
+ expect(screen.getByRole("button", { name: "Show password" })).toBeDisabled();
108
+ rerender(<Input type="password" aria-label="Password" />);
109
+ expect(screen.getByRole("button", { name: "Show password" })).toBeEnabled();
110
+ });
111
+
112
+ it("keeps a revealed password out of spellcheck, autocorrect and autocapitalisation", () => {
113
+ // Revealing swaps the type to `text`, which in some engines makes the value a candidate for
114
+ // all three — two of which would rewrite what the user typed.
115
+ render(<Input type="password" aria-label="Password" defaultValue="" />);
116
+ const input = screen.getByLabelText("Password");
117
+ fireEvent.click(screen.getByRole("button", { name: "Show password" }));
118
+ expect(input).toHaveAttribute("type", "text");
119
+ expect(input).toHaveAttribute("spellcheck", "false");
120
+ expect(input).toHaveAttribute("autocorrect", "off");
121
+ expect(input).toHaveAttribute("autocapitalize", "none");
122
+ });
123
+ });