@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.
- package/README.md +7 -6
- package/package.json +2 -2
- package/src/AppShell.test.tsx +52 -4
- package/src/AppShell.tsx +35 -17
- package/src/ConfirmDialog.tsx +4 -4
- package/src/EmptyState.test.tsx +30 -0
- package/src/EmptyState.tsx +31 -8
- package/src/ErrorState.tsx +8 -4
- package/src/Field.test.tsx +20 -0
- package/src/Field.tsx +2 -2
- package/src/LoginView.test.tsx +34 -2
- package/src/LoginView.tsx +33 -20
- package/src/UserMenu.test.tsx +2 -2
- package/src/bootstrap.tsx +5 -2
- package/src/format.test.tsx +3 -3
- package/src/index.ts +24 -3
- package/src/locale.test.tsx +195 -3
- package/src/locale.tsx +196 -11
- package/src/markers.test.ts +2 -0
- package/src/nav.ts +2 -2
- package/src/router.tsx +13 -3
- package/src/sso.ts +2 -2
- package/src/styles.test.ts +84 -34
- package/src/styles.ts +233 -31
- package/src/tokens.guard.test.ts +49 -2
- package/src/ui/Combobox.test.tsx +90 -0
- package/src/ui/Combobox.tsx +247 -41
- package/src/ui/DatePicker.tsx +16 -7
- package/src/ui/Tabs.test.tsx +28 -0
- package/src/ui/Tabs.tsx +14 -0
- package/src/uiText.literals.test.ts +199 -0
- package/src/uiText.test.tsx +15 -1
- package/src/uiText.tsx +71 -2
package/src/ui/Combobox.test.tsx
CHANGED
|
@@ -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
|
+
});
|
package/src/ui/Combobox.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
37
|
-
export
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
198
|
+
multiple,
|
|
199
|
+
selectedValues,
|
|
200
|
+
onSelectOption,
|
|
201
|
+
onClearSelection,
|
|
202
|
+
mirroredLabel = "",
|
|
203
|
+
tokens,
|
|
204
|
+
onRemoveLast,
|
|
42
205
|
loading = false,
|
|
43
|
-
loadingText
|
|
44
|
-
noOptionsText
|
|
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
|
-
}:
|
|
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
|
|
60
|
-
const
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
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(
|
|
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
|
-
}, [
|
|
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 (
|
|
117
|
-
|
|
286
|
+
if (option === null) {
|
|
287
|
+
onClearSelection();
|
|
288
|
+
setQuery("");
|
|
289
|
+
setOpen(false);
|
|
290
|
+
setActiveValue(null);
|
|
291
|
+
return;
|
|
118
292
|
}
|
|
119
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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=
|
|
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
|
|
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">
|
|
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">
|
|
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
|
|
451
|
+
const selected = chosen.has(option.value);
|
|
246
452
|
return (
|
|
247
453
|
<button
|
|
248
454
|
key={option.value}
|
package/src/ui/DatePicker.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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=
|
|
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=
|
|
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>)}
|
package/src/ui/Tabs.test.tsx
CHANGED
|
@@ -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);
|