@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.
- package/README.md +57 -21
- package/package.json +6 -5
- package/src/AppShell.test.tsx +323 -4
- package/src/AppShell.tsx +401 -66
- package/src/EmptyState.test.tsx +30 -0
- package/src/EmptyState.tsx +23 -3
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.test.tsx +34 -2
- package/src/LoginView.tsx +43 -18
- package/src/ModuleNav.test.tsx +17 -10
- package/src/ModuleNav.tsx +35 -3
- package/src/Page.tsx +23 -1
- package/src/ProfileView.test.tsx +1 -1
- package/src/ProfileView.tsx +2 -4
- package/src/SettingsPage.tsx +50 -0
- package/src/SplitPage.tsx +150 -0
- package/src/UserMenu.test.tsx +28 -5
- package/src/UserMenu.tsx +15 -9
- package/src/admin/AuditLogAdmin.tsx +21 -7
- package/src/admin/GroupCreate.tsx +17 -3
- package/src/admin/GroupDetail.tsx +48 -13
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +40 -11
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +212 -8
- package/src/admin/fieldErrors.ts +45 -0
- package/src/bootstrap.test.tsx +208 -0
- package/src/bootstrap.tsx +121 -5
- package/src/breakpoints.ts +41 -0
- package/src/dataview/DataView.tsx +12 -5
- package/src/dataview/DataViewCardList.tsx +8 -7
- package/src/dataview/DataViewPagination.tsx +15 -8
- package/src/dataview/DataViewTable.tsx +32 -21
- package/src/dataview/README.md +13 -2
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +31 -1
- package/src/dataview/types.ts +26 -3
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +56 -6
- package/src/layout.manifest.json +118 -0
- package/src/layout.manifest.test.ts +205 -0
- package/src/layout.test.tsx +198 -1
- package/src/layout.tsx +208 -11
- package/src/layoutContract.test.tsx +311 -2
- package/src/layoutContract.ts +44 -3
- package/src/layoutDeclaration.test.ts +435 -0
- package/src/layoutDeclaration.ts +531 -0
- package/src/locale.tsx +12 -0
- package/src/markers.test.ts +27 -5
- package/src/nav.test.ts +234 -4
- package/src/nav.ts +180 -6
- package/src/navActive.test.ts +115 -0
- package/src/navActive.ts +119 -0
- package/src/navLink.tsx +20 -2
- package/src/previewBridge.test.ts +327 -0
- package/src/previewBridge.ts +278 -0
- package/src/raw.d.ts +14 -2
- package/src/review.test.tsx +272 -0
- package/src/router.test.tsx +575 -2
- package/src/router.tsx +212 -19
- package/src/styles.test.ts +535 -58
- package/src/styles.ts +1130 -111
- package/src/theme.test.tsx +29 -0
- package/src/theme.themes.test.ts +13 -7
- package/src/theme.tsx +30 -33
- package/src/themes.ts +54 -0
- package/src/toast.tsx +2 -1
- package/src/tokens.guard.test.ts +239 -0
- package/src/typography.test.tsx +213 -0
- package/src/typography.tsx +255 -0
- package/src/ui/Avatar.test.tsx +63 -0
- package/src/ui/Avatar.tsx +65 -0
- package/src/ui/Button.test.tsx +69 -3
- package/src/ui/Button.tsx +57 -4
- package/src/ui/Card.test.tsx +13 -0
- package/src/ui/Card.tsx +28 -1
- package/src/ui/Checkbox.tsx +10 -2
- package/src/ui/Combobox.test.tsx +139 -0
- package/src/ui/Combobox.tsx +255 -43
- package/src/ui/DatePicker.tsx +44 -12
- package/src/ui/Input.test.tsx +123 -0
- package/src/ui/Input.tsx +65 -2
- package/src/ui/Menu.tsx +16 -5
- package/src/ui/Popover.tsx +13 -0
- package/src/ui/Radio.tsx +10 -5
- package/src/ui/Select.test.tsx +232 -0
- package/src/ui/Select.tsx +177 -8
- package/src/ui/Switch.tsx +10 -2
- package/src/ui/Tabs.test.tsx +28 -0
- package/src/ui/Tabs.tsx +30 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.literals.test.ts +199 -0
- package/src/uiText.tsx +36 -0
- package/src/unwrap.test.ts +132 -0
- package/src/unwrap.ts +118 -32
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
|
|
@@ -72,18 +234,32 @@ export function Combobox({
|
|
|
72
234
|
const isOpen = open && disabled !== true;
|
|
73
235
|
|
|
74
236
|
const renderedOptions = useMemo(() => {
|
|
75
|
-
|
|
76
|
-
|
|
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).
|
|
80
|
-
}, [options, query, resolve
|
|
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
|
-
|
|
86
|
-
|
|
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(
|
|
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
|
-
}, [
|
|
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 (
|
|
111
|
-
|
|
286
|
+
if (option === null) {
|
|
287
|
+
onClearSelection();
|
|
288
|
+
setQuery("");
|
|
289
|
+
setOpen(false);
|
|
290
|
+
setActiveValue(null);
|
|
291
|
+
return;
|
|
112
292
|
}
|
|
113
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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=
|
|
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
|
|
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">
|
|
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">
|
|
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
|
|
451
|
+
const selected = chosen.has(option.value);
|
|
240
452
|
return (
|
|
241
453
|
<button
|
|
242
454
|
key={option.value}
|
package/src/ui/DatePicker.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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=
|
|
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=
|
|
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
|
-
|
|
418
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|