@terpjs/react-core 0.10.0 → 0.12.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.
@@ -127,3 +127,93 @@ describe("Combobox", () => {
127
127
  expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
128
128
  });
129
129
  });
130
+
131
+ describe("Combobox multiple", () => {
132
+ it("accumulates a set, keeps the list open, and clears the filter between picks", () => {
133
+ // The reason this mode exists: a set-valued field had no control, and the absence
134
+ // produced comma-separated text boxes with the legal values in a grey hint beside them
135
+ // — a closed enum typed as free text, so validation the value set could have enforced
136
+ // was lost. Picking one member of a set is almost never the last thing a user wants, so
137
+ // the list staying open is the behaviour, not a detail.
138
+ const onChange = vi.fn();
139
+ render(<Combobox multiple aria-label="Fields" options={options} onChange={onChange} />);
140
+ const input = screen.getByRole("combobox", { name: "Fields" });
141
+
142
+ fireEvent.focus(input);
143
+ fireEvent.click(screen.getByRole("option", { name: "Netherlands" }));
144
+ expect(onChange).toHaveBeenLastCalledWith(["nl"], [options[0]]);
145
+ // Still open, and the filter is empty so the next pick starts from the whole list.
146
+ expect(screen.getByRole("listbox")).toBeInTheDocument();
147
+ expect(input).toHaveValue("");
148
+
149
+ fireEvent.click(screen.getByRole("option", { name: "France" }));
150
+ expect(onChange).toHaveBeenLastCalledWith(["nl", "fr"], [options[0], options[3]]);
151
+
152
+ // Selecting an already-chosen option removes it — one control, both directions.
153
+ fireEvent.click(screen.getByRole("option", { name: "Netherlands" }));
154
+ expect(onChange).toHaveBeenLastCalledWith(["fr"], [options[3]]);
155
+ });
156
+
157
+ it("says it is multi-selectable and marks every chosen option", () => {
158
+ render(<Combobox multiple aria-label="Fields" options={options} defaultValue={["nl", "fr"]} defaultOpen />);
159
+ expect(screen.getByRole("listbox")).toHaveAttribute("aria-multiselectable", "true");
160
+ expect(screen.getByRole("option", { name: "Netherlands" })).toHaveAttribute("aria-selected", "true");
161
+ expect(screen.getByRole("option", { name: "France" })).toHaveAttribute("aria-selected", "true");
162
+ expect(screen.getByRole("option", { name: "Belgium" })).toHaveAttribute("aria-selected", "false");
163
+ });
164
+
165
+ it("gives every token a remove control whose name says which token it removes", () => {
166
+ // N identical "Remove" buttons is not a keyboard-accessible token field: the name has to
167
+ // carry the option, or a screen-reader user cannot tell which one they are about to
168
+ // remove. These are real buttons and real tab stops — the accessible half of the
169
+ // Backspace shortcut rather than a duplicate of it, because a shortcut is only
170
+ // discoverable if you already know about it.
171
+ const onChange = vi.fn();
172
+ render(
173
+ <Combobox multiple aria-label="Fields" options={options} defaultValue={["nl", "fr"]} onChange={onChange} />,
174
+ );
175
+ fireEvent.click(screen.getByRole("button", { name: "Remove Netherlands" }));
176
+ expect(onChange).toHaveBeenLastCalledWith(["fr"], [options[3]]);
177
+ });
178
+
179
+ it("removes the last token on Backspace only when the filter is empty", () => {
180
+ const onChange = vi.fn();
181
+ render(
182
+ <Combobox multiple aria-label="Fields" options={options} defaultValue={["nl", "fr"]} onChange={onChange} />,
183
+ );
184
+ const input = screen.getByRole("combobox", { name: "Fields" });
185
+
186
+ // With text in the box, Backspace belongs to the text: eating a token here would
187
+ // delete a selection while the user thinks they are correcting a typo.
188
+ fireEvent.change(input, { target: { value: "Bel" } });
189
+ fireEvent.keyDown(input, { key: "Backspace" });
190
+ expect(onChange).not.toHaveBeenCalled();
191
+
192
+ fireEvent.change(input, { target: { value: "" } });
193
+ fireEvent.keyDown(input, { key: "Backspace" });
194
+ expect(onChange).toHaveBeenLastCalledWith(["nl"], [options[0]]);
195
+ });
196
+
197
+ it("keeps a controlled set when the parent declines the change", () => {
198
+ // The same invariant single mode has: the control shows what the prop says, not what
199
+ // was clicked. A token that appears because it was clicked and not because the parent
200
+ // accepted it is a field that disagrees with the state it is bound to.
201
+ const onChange = vi.fn();
202
+ render(
203
+ <Combobox multiple aria-label="Fields" options={options} value={["nl"]} onChange={onChange} defaultOpen />,
204
+ );
205
+ fireEvent.click(screen.getByRole("option", { name: "France" }));
206
+ expect(onChange).toHaveBeenLastCalledWith(["nl", "fr"], [options[0], options[3]]);
207
+ expect(screen.getByRole("button", { name: "Remove Netherlands" })).toBeInTheDocument();
208
+ expect(screen.queryByRole("button", { name: "Remove France" })).toBeNull();
209
+ });
210
+
211
+ it("clears the whole set through the clear control", () => {
212
+ const onChange = vi.fn();
213
+ render(
214
+ <Combobox multiple clearable aria-label="Fields" options={options} defaultValue={["nl", "fr"]} onChange={onChange} />,
215
+ );
216
+ fireEvent.click(screen.getByRole("button", { name: "Clear all selections" }));
217
+ expect(onChange).toHaveBeenLastCalledWith([], []);
218
+ });
219
+ });
@@ -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
@@ -79,17 +241,25 @@ export function Combobox({
79
241
  // Folding both sides with the same host locale does not rescue it: the needle comes from a
80
242
  // keyboard and the haystack from a server, and the two agree only when the fold is invariant.
81
243
  const normalized = query.trim().toLowerCase();
82
- if (normalized.length === 0 || selectedOption !== null && query === resolve(selectedOption.label)) {
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)) {
83
249
  return options;
84
250
  }
85
251
  return options.filter((option) => resolve(option.label).toLowerCase().includes(normalized));
86
- }, [options, query, resolve, selectedOption]);
252
+ }, [multiple, mirroredLabel, options, query, resolve]);
87
253
  const enabledOptions = renderedOptions.filter((option) => !option.disabled);
88
254
  const activeOption = renderedOptions.find((option) => option.value === activeValue) ?? enabledOptions[0] ?? null;
89
255
 
90
256
  useEffect(() => {
91
- setQuery(selectedOption ? resolve(selectedOption.label) : "");
92
- }, [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]);
93
263
 
94
264
  useEffect(() => {
95
265
  if (!open) {
@@ -98,7 +268,7 @@ export function Combobox({
98
268
  function onPointerDown(event: PointerEvent | MouseEvent) {
99
269
  if (rootRef.current !== null && event.target instanceof Node && !rootRef.current.contains(event.target)) {
100
270
  setOpen(false);
101
- setQuery(selectedOption ? resolve(selectedOption.label) : "");
271
+ setQuery(multiple ? "" : mirroredLabel);
102
272
  }
103
273
  }
104
274
  document.addEventListener("pointerdown", onPointerDown);
@@ -107,19 +277,37 @@ export function Combobox({
107
277
  document.removeEventListener("pointerdown", onPointerDown);
108
278
  document.removeEventListener("mousedown", onPointerDown);
109
279
  };
110
- }, [open, resolve, selectedOption]);
280
+ }, [multiple, mirroredLabel, open]);
111
281
 
112
282
  function commit(option: ComboboxOption | null) {
113
283
  if (option?.disabled) {
114
284
  return;
115
285
  }
116
- if (value === undefined) {
117
- setUncontrolledValue(option?.value ?? null);
286
+ if (option === null) {
287
+ onClearSelection();
288
+ setQuery("");
289
+ setOpen(false);
290
+ setActiveValue(null);
291
+ return;
118
292
  }
119
- 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);
120
309
  setOpen(false);
121
- setActiveValue(option?.value ?? null);
122
- onChange?.(option?.value ?? null, option);
310
+ setActiveValue(option.value);
123
311
  }
124
312
 
125
313
  function moveActive(direction: 1 | -1 | "first" | "last") {
@@ -175,7 +363,16 @@ export function Combobox({
175
363
  if (open) {
176
364
  event.preventDefault();
177
365
  setOpen(false);
178
- 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?.();
179
376
  }
180
377
  break;
181
378
  default:
@@ -185,7 +382,8 @@ export function Combobox({
185
382
 
186
383
  return (
187
384
  <div ref={rootRef} data-terp="combobox">
188
- <div data-terp="combobox-field">
385
+ <div data-terp="combobox-field" data-multiple={multiple ? "true" : undefined}>
386
+ {tokens}
189
387
  <input
190
388
  {...rest}
191
389
  ref={inputRef}
@@ -203,7 +401,9 @@ export function Combobox({
203
401
  onFocus?.(event);
204
402
  if (!disabled) {
205
403
  setOpen(true);
206
- setActiveValue(selectedOption?.value ?? enabledOptions[0]?.value ?? null);
404
+ setActiveValue(
405
+ (multiple ? null : selectedValues[0] ?? null) ?? enabledOptions[0]?.value ?? null,
406
+ );
207
407
  }
208
408
  }}
209
409
  onBlur={onBlur}
@@ -211,18 +411,15 @@ export function Combobox({
211
411
  setQuery(event.currentTarget.value);
212
412
  setOpen(true);
213
413
  setActiveValue(null);
214
- if (selectedValue !== null && value === undefined) {
215
- setUncontrolledValue(null);
216
- }
217
414
  }}
218
415
  onKeyDown={handleKeyDown}
219
416
  style={style}
220
417
  />
221
- {clearable && !disabled && query.length > 0 && (
418
+ {clearable && !disabled && (query.length > 0 || (multiple && chosen.size > 0)) && (
222
419
  <button
223
420
  type="button"
224
421
  data-terp="iconbutton"
225
- aria-label="Clear selection"
422
+ aria-label={multiple ? strings.clearAllSelections : strings.clearSelection}
226
423
  onClick={() => {
227
424
  commit(null);
228
425
  inputRef.current?.focus();
@@ -233,16 +430,25 @@ export function Combobox({
233
430
  )}
234
431
  </div>
235
432
  {isOpen && (
236
- <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
+ >
237
439
  {loading ? (
238
- <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>
239
443
  ) : renderedOptions.length === 0 ? (
240
- <div data-terp="combobox-empty">{resolve(noOptionsText)}</div>
444
+ <div data-terp="combobox-empty">
445
+ {resolve(noOptionsText ?? strings.comboboxNoOptions)}
446
+ </div>
241
447
  ) : (
242
448
  renderedOptions.map((option) => {
243
449
  const label = resolve(option.label);
244
450
  const active = option.value === activeOption?.value;
245
- const selected = option.value === selectedValue;
451
+ const selected = chosen.has(option.value);
246
452
  return (
247
453
  <button
248
454
  key={option.value}
@@ -4,7 +4,7 @@ import type { KeyboardEvent } from "react";
4
4
  import { formatDate } from "../format";
5
5
  import { useLocale } from "../locale";
6
6
  import { injectTerpStyles } from "../styles";
7
- import { useUiText } from "../uiText";
7
+ import { useStrings, useUiText } from "../uiText";
8
8
  import type { UiText } from "../uiText";
9
9
  import { Popover } from "./Popover";
10
10
 
@@ -51,7 +51,7 @@ export function DatePicker({
51
51
  min,
52
52
  max,
53
53
  disabled = false,
54
- placeholder = "Select date",
54
+ placeholder,
55
55
  "aria-label": ariaLabel,
56
56
  "aria-invalid": ariaInvalid,
57
57
  defaultOpen = false,
@@ -66,8 +66,15 @@ export function DatePicker({
66
66
  // was clicking the disabled trigger.
67
67
  const [open, setOpen] = useState(defaultOpen && !disabled);
68
68
  const locale = useDateLocale();
69
+ const strings = useStrings();
69
70
  const resolve = useUiText();
70
- 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);
71
78
 
72
79
  function commit(next: Date) {
73
80
  if (value === undefined) {
@@ -121,7 +128,7 @@ export function DateRangePicker({
121
128
  min,
122
129
  max,
123
130
  disabled = false,
124
- placeholder = "Select date range",
131
+ placeholder,
125
132
  "aria-label": ariaLabel,
126
133
  "aria-invalid": ariaInvalid,
127
134
  defaultOpen = false,
@@ -136,9 +143,10 @@ export function DateRangePicker({
136
143
  // was clicking the disabled trigger.
137
144
  const [open, setOpen] = useState(defaultOpen && !disabled);
138
145
  const locale = useDateLocale();
146
+ const strings = useStrings();
139
147
  const resolve = useUiText();
140
148
  const formatted = selected.start === null
141
- ? resolve(placeholder)
149
+ ? resolve(placeholder ?? strings.selectDateRange)
142
150
  : selected.end === null
143
151
  ? `${formatDate(selected.start, locale)} –`
144
152
  : `${formatDate(selected.start, locale)} – ${formatDate(selected.end, locale)}`;
@@ -203,6 +211,7 @@ interface CalendarProps {
203
211
  }
204
212
 
205
213
  function Calendar({ mode, locale, visibleSeed, selected = null, range, min, max, onSelect, onRangeSelect, onEscape }: CalendarProps) {
214
+ const strings = useStrings();
206
215
  const gridId = useId();
207
216
  const titleId = useId();
208
217
  const minDate = normalizeDate(min);
@@ -344,9 +353,9 @@ function Calendar({ mode, locale, visibleSeed, selected = null, range, min, max,
344
353
  // wcag2a/aa tags the lane runs, so opening the calendar in stage 4 did not surface it.
345
354
  <div role="dialog" aria-modal="false" aria-labelledby={titleId} data-terp="calendar">
346
355
  <div data-terp="calendar-header">
347
- <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>
348
357
  <div id={titleId} data-terp="calendar-title">{formatMonth(month, locale)}</div>
349
- <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>
350
359
  </div>
351
360
  <div data-terp="calendar-week" aria-hidden="true">
352
361
  {weekdays.map((day) => <div key={day} data-terp="calendar-weekday">{day}</div>)}
@@ -27,3 +27,31 @@ describe("Tabs", () => {
27
27
  expect(onChange).toHaveBeenCalledWith("audit");
28
28
  });
29
29
  });
30
+
31
+ describe("Tabs with a single tab", () => {
32
+ it("renders the content bare, with no tablist to choose from", () => {
33
+ // A tab set over one tab costs a row of the screen to offer nothing, and to a screen
34
+ // reader it is worse than decorative: it announces "tab 1 of 1" and the only
35
+ // affordance is already selected.
36
+ render(<Tabs label="Sections" tabs={[{ value: "only", label: "Only", content: "Body" }]} />);
37
+ expect(screen.queryByRole("tablist")).toBeNull();
38
+ expect(screen.queryByRole("tab")).toBeNull();
39
+ // The panel goes too: a tabpanel exists to be labelled by the tab that reveals it, and
40
+ // there is nothing left to reveal.
41
+ expect(screen.queryByRole("tabpanel")).toBeNull();
42
+ expect(screen.getByText("Body")).toBeInTheDocument();
43
+ });
44
+
45
+ it("keeps the chrome when that single tab is disabled", () => {
46
+ // Here the tab set carries real information — this section exists and is unavailable —
47
+ // and rendering its content bare would show what the caller marked unreachable.
48
+ render(
49
+ <Tabs
50
+ label="Sections"
51
+ tabs={[{ value: "only", label: "Only", content: "Body", disabled: true }]}
52
+ />,
53
+ );
54
+ expect(screen.getByRole("tablist")).toBeInTheDocument();
55
+ expect(screen.getByRole("tab", { name: "Only" })).toBeDisabled();
56
+ });
57
+ });
package/src/ui/Tabs.tsx CHANGED
@@ -38,6 +38,20 @@ export function Tabs({ tabs, value, defaultValue, onChange, label }: TabsProps)
38
38
  // interpolate caller strings into ids is written out at AppShell's nav-group labels.
39
39
  const selectedIndex = tabs.findIndex((tab) => tab.value === selectedTab?.value);
40
40
 
41
+ // One usable tab is not a choice, so it gets no chrome. A tablist over a single tab costs
42
+ // a row of the screen to offer nothing, and it is worse than decorative to a screen
43
+ // reader: the set announces "tab 1 of 1" and the only affordance is already selected.
44
+ // Rendering the content bare also drops the tabpanel, which is correct rather than
45
+ // convenient — a panel exists to be labelled by the tab that reveals it, and there is
46
+ // no revealing left to do.
47
+ //
48
+ // A single DISABLED tab keeps the chrome, deliberately: there the tab set is carrying
49
+ // real information (this section exists and is unavailable), and silently rendering its
50
+ // content would show what the caller marked unreachable.
51
+ if (tabs.length === 1 && !tabs[0]?.disabled) {
52
+ return <>{tabs[0]?.content}</>;
53
+ }
54
+
41
55
  function select(next: string) {
42
56
  if (value === undefined) {
43
57
  setUncontrolledValue(next);