@tensor_1/react 2.35.0 → 2.36.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 +141 -15
- package/dist/components/Avatar.d.ts +26 -4
- package/dist/components/Avatar.js +26 -3
- package/dist/components/CommandPalette.d.ts +6 -1
- package/dist/components/CommandPalette.js +9 -3
- package/dist/components/DateFieldParts.d.ts +61 -0
- package/dist/components/DateFieldParts.js +195 -0
- package/dist/components/DatePicker.d.ts +129 -0
- package/dist/components/DatePicker.js +340 -0
- package/dist/components/DateRangePicker.d.ts +34 -3
- package/dist/components/DateRangePicker.js +120 -17
- package/dist/components/EntityPicker.d.ts +29 -4
- package/dist/components/EntityPicker.js +20 -8
- package/dist/components/InlineEdit.d.ts +74 -0
- package/dist/components/InlineEdit.js +176 -0
- package/dist/components/Radio.d.ts +23 -2
- package/dist/components/Radio.js +18 -3
- package/dist/components/SchedulingTimeline.d.ts +130 -15
- package/dist/components/SchedulingTimeline.js +765 -141
- package/dist/components/SearchSuggest.d.ts +12 -1
- package/dist/components/SearchSuggest.js +7 -2
- package/dist/components/Tree.d.ts +30 -2
- package/dist/components/Tree.js +135 -10
- package/dist/date-field-model.d.ts +40 -0
- package/dist/date-field-model.js +79 -0
- package/dist/index.cjs +3854 -2068
- package/dist/index.d.cts +808 -262
- package/dist/index.d.ts +9 -6
- package/dist/index.js +40 -4
- package/dist/labels-context.d.ts +14 -1
- package/dist/scheduling-timeline-model.d.ts +61 -11
- package/dist/scheduling-timeline-model.js +85 -26
- package/dist/scheduling-timeline-rows.d.ts +40 -0
- package/dist/scheduling-timeline-rows.js +42 -0
- package/package.json +5 -3
|
@@ -3,7 +3,11 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { addCalendarDays, addCalendarMonths, buildMonthGrid, isDateInRange, isDateUnavailable, selectRangeDate, startOfMonth } from "../calendar-model.js";
|
|
5
5
|
import { cx } from "../cx.js";
|
|
6
|
+
import { formatDateKey } from "../date-field-model.js";
|
|
7
|
+
import { CalendarIcon } from "../icons/components.js";
|
|
6
8
|
import { useUixLabels } from "../labels-context.js";
|
|
9
|
+
import { localDateKey, useFieldPopover, weekdayNames } from "./DateFieldParts.js";
|
|
10
|
+
import { Popover } from "./Popover.js";
|
|
7
11
|
const DEFAULT_DATE_RANGE_PICKER_LABELS = {
|
|
8
12
|
previous: "Previous",
|
|
9
13
|
previousMonth: "Show previous month",
|
|
@@ -14,16 +18,14 @@ const DEFAULT_DATE_RANGE_PICKER_LABELS = {
|
|
|
14
18
|
rangeSelected: "{start} to {end} selected.",
|
|
15
19
|
rangeStart: "start of selected range",
|
|
16
20
|
rangeEnd: "end of selected range",
|
|
17
|
-
inRange: "in selected range"
|
|
21
|
+
inRange: "in selected range",
|
|
22
|
+
placeholder: "Choose dates",
|
|
23
|
+
fieldStart: "{start} \u2013",
|
|
24
|
+
fieldRange: "{start} \u2013 {end}"
|
|
18
25
|
};
|
|
19
|
-
const localDateKey = (now = /* @__PURE__ */ new Date()) => `${now.getFullYear().toString().padStart(4, "0")}-${(now.getMonth() + 1).toString().padStart(2, "0")}-${now.getDate().toString().padStart(2, "0")}`;
|
|
20
26
|
const monthLabel = (date, locale) => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", timeZone: "UTC" }).format(/* @__PURE__ */ new Date(`${date}T00:00:00Z`));
|
|
21
27
|
const dateLabel = (date, locale) => new Intl.DateTimeFormat(locale, { dateStyle: "full", timeZone: "UTC" }).format(/* @__PURE__ */ new Date(`${date}T00:00:00Z`));
|
|
22
|
-
|
|
23
|
-
const fmt = new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" });
|
|
24
|
-
return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(Date.UTC(2024, 0, 1 + i))));
|
|
25
|
-
};
|
|
26
|
-
function DateRangePicker({
|
|
28
|
+
function RangeCalendar({
|
|
27
29
|
value,
|
|
28
30
|
onChange,
|
|
29
31
|
visibleMonth: controlledMonth,
|
|
@@ -38,18 +40,18 @@ function DateRangePicker({
|
|
|
38
40
|
invalidMessage = "The selected date range is invalid.",
|
|
39
41
|
disabled,
|
|
40
42
|
className,
|
|
41
|
-
|
|
42
|
-
today
|
|
43
|
+
words: labels,
|
|
44
|
+
today,
|
|
45
|
+
weekStartsOn = 1,
|
|
46
|
+
autoFocus = false
|
|
43
47
|
}) {
|
|
44
48
|
const id = useId();
|
|
45
|
-
const
|
|
46
|
-
const labels = { ...DEFAULT_DATE_RANGE_PICKER_LABELS, ...uixLabels.dateRangePicker, ...labelOverrides };
|
|
47
|
-
const weekdays = useMemo(() => weekdayNames(locale), [locale]);
|
|
49
|
+
const weekdays = useMemo(() => weekdayNames(locale, weekStartsOn), [locale, weekStartsOn]);
|
|
48
50
|
const [internalMonth, setInternalMonth] = useState(() => startOfMonth(controlledMonth ?? value.start ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)));
|
|
49
51
|
const visibleMonth = startOfMonth(controlledMonth ?? internalMonth);
|
|
50
52
|
const [activeDate, setActiveDate] = useState(value.end ?? value.start ?? visibleMonth);
|
|
51
53
|
const rootRef = useRef(null);
|
|
52
|
-
const pendingFocus = useRef(
|
|
54
|
+
const pendingFocus = useRef(autoFocus);
|
|
53
55
|
const count = Math.max(1, Math.min(3, months));
|
|
54
56
|
const calendars = useMemo(() => Array.from({ length: count }, (_, index) => {
|
|
55
57
|
const month = startOfMonth(addCalendarMonths(visibleMonth, index));
|
|
@@ -65,7 +67,7 @@ function DateRangePicker({
|
|
|
65
67
|
}, [activeDate, calendars]);
|
|
66
68
|
useEffect(() => {
|
|
67
69
|
if (!pendingFocus.current) return;
|
|
68
|
-
rootRef.current?.querySelector(`[data-date="${activeDate}"]`)?.focus();
|
|
70
|
+
rootRef.current?.querySelector(`[data-date="${activeDate}"]`)?.focus({ preventScroll: autoFocus });
|
|
69
71
|
pendingFocus.current = false;
|
|
70
72
|
}, [activeDate, visibleMonth]);
|
|
71
73
|
const setMonth = (month) => {
|
|
@@ -93,8 +95,8 @@ function DateRangePicker({
|
|
|
93
95
|
if (event.key === "Home" || event.key === "End") {
|
|
94
96
|
event.preventDefault();
|
|
95
97
|
const weekday = (/* @__PURE__ */ new Date(`${date}T00:00:00Z`)).getUTCDay();
|
|
96
|
-
const
|
|
97
|
-
focusDate(addCalendarDays(date, event.key === "Home" ? -
|
|
98
|
+
const index = (weekday - weekStartsOn + 7) % 7;
|
|
99
|
+
focusDate(addCalendarDays(date, event.key === "Home" ? -index : 6 - index));
|
|
98
100
|
} else if (event.key === "PageUp" || event.key === "PageDown") {
|
|
99
101
|
event.preventDefault();
|
|
100
102
|
focusDate(addCalendarMonths(date, event.key === "PageUp" ? -1 : 1));
|
|
@@ -113,7 +115,7 @@ function DateRangePicker({
|
|
|
113
115
|
/* @__PURE__ */ jsx("h4", { children: monthLabel(month, locale) }),
|
|
114
116
|
/* @__PURE__ */ jsx("div", { className: "uix-date-range-picker__weekdays", "aria-hidden": "true", children: weekdays.map((day, index) => /* @__PURE__ */ jsx("span", { children: day }, index)) }),
|
|
115
117
|
/* @__PURE__ */ jsxs("div", { className: "uix-date-range-picker__grid", role: "group", "aria-label": monthLabel(month, locale), children: [
|
|
116
|
-
Array.from({ length: ((/* @__PURE__ */ new Date(`${month}T00:00:00Z`)).getUTCDay() +
|
|
118
|
+
Array.from({ length: ((/* @__PURE__ */ new Date(`${month}T00:00:00Z`)).getUTCDay() - weekStartsOn + 7) % 7 }, (_, index) => /* @__PURE__ */ jsx("span", { "aria-hidden": "true" }, `blank-${index}`)),
|
|
117
119
|
days.map((day) => {
|
|
118
120
|
const unavailable = disabled || isDateUnavailable(day.date, { min, max, isDisabled: isDateDisabled });
|
|
119
121
|
const edge = day.date === value.start ? "start" : day.date === value.end ? "end" : void 0;
|
|
@@ -143,6 +145,107 @@ function DateRangePicker({
|
|
|
143
145
|
rangeInvalid && /* @__PURE__ */ jsx("p", { className: "uix-field__error", role: "alert", children: invalidMessage })
|
|
144
146
|
] });
|
|
145
147
|
}
|
|
148
|
+
function DateRangePicker({ labels: labelOverrides, ...props }) {
|
|
149
|
+
const uixLabels = useUixLabels();
|
|
150
|
+
const words = { ...DEFAULT_DATE_RANGE_PICKER_LABELS, ...uixLabels.dateRangePicker, ...labelOverrides };
|
|
151
|
+
if (props.mode === "field") return /* @__PURE__ */ jsx(DateRangeField, { ...props, words });
|
|
152
|
+
return /* @__PURE__ */ jsx(RangeCalendar, { ...props, words });
|
|
153
|
+
}
|
|
154
|
+
function DateRangeField(props) {
|
|
155
|
+
const {
|
|
156
|
+
value,
|
|
157
|
+
onChange,
|
|
158
|
+
locale,
|
|
159
|
+
label = "Choose date range",
|
|
160
|
+
invalid,
|
|
161
|
+
disabled,
|
|
162
|
+
className,
|
|
163
|
+
words,
|
|
164
|
+
formatDate,
|
|
165
|
+
id: idProp,
|
|
166
|
+
size = "md",
|
|
167
|
+
placement = "bottom-start",
|
|
168
|
+
onOpenChange,
|
|
169
|
+
"aria-describedby": ariaDescribedBy,
|
|
170
|
+
"aria-invalid": ariaInvalid
|
|
171
|
+
} = props;
|
|
172
|
+
const uid = useId();
|
|
173
|
+
const id = idProp ?? `${uid}-trigger`;
|
|
174
|
+
const popoverId = `${uid}-months`;
|
|
175
|
+
const valueId = `${uid}-value`;
|
|
176
|
+
const anchorRef = useRef(null);
|
|
177
|
+
const triggerRef = useRef(null);
|
|
178
|
+
const { open, hide, triggerProps } = useFieldPopover(popoverId, () => triggerRef.current?.focus({ preventScroll: true }), onOpenChange);
|
|
179
|
+
const [labelled, setLabelled] = useState(false);
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
setLabelled((triggerRef.current?.labels?.length ?? 0) > 0);
|
|
182
|
+
}, [id]);
|
|
183
|
+
const format = (date) => formatDate?.(date) ?? formatDateKey(date, locale);
|
|
184
|
+
const text = !value.start ? null : !value.end ? words.fieldStart.replace("{start}", format(value.start)) : words.fieldRange.replace("{start}", format(value.start)).replace("{end}", format(value.end));
|
|
185
|
+
const rangeInvalid = invalid || !!value.start && !!value.end && value.start > value.end;
|
|
186
|
+
const describedBy = [ariaDescribedBy, labelled && text ? valueId : void 0].filter(Boolean).join(" ") || void 0;
|
|
187
|
+
return /* @__PURE__ */ jsxs(
|
|
188
|
+
"div",
|
|
189
|
+
{
|
|
190
|
+
ref: anchorRef,
|
|
191
|
+
className: cx("uix-date-range-field", className),
|
|
192
|
+
onKeyDown: (event) => {
|
|
193
|
+
if (event.key !== "Escape" || !open) return;
|
|
194
|
+
event.stopPropagation();
|
|
195
|
+
event.preventDefault();
|
|
196
|
+
hide();
|
|
197
|
+
},
|
|
198
|
+
children: [
|
|
199
|
+
/* @__PURE__ */ jsxs(
|
|
200
|
+
"button",
|
|
201
|
+
{
|
|
202
|
+
ref: triggerRef,
|
|
203
|
+
type: "button",
|
|
204
|
+
id,
|
|
205
|
+
disabled,
|
|
206
|
+
className: cx("uix-input", size === "sm" && "uix-input--sm", "uix-date-range-field__trigger"),
|
|
207
|
+
"aria-describedby": describedBy,
|
|
208
|
+
"aria-invalid": rangeInvalid || ariaInvalid === true || ariaInvalid === "true" || void 0,
|
|
209
|
+
...triggerProps,
|
|
210
|
+
children: [
|
|
211
|
+
/* @__PURE__ */ jsx("span", { className: "uix-date-range-field__icon", "aria-hidden": "true", children: /* @__PURE__ */ jsx(CalendarIcon, { size: "sm" }) }),
|
|
212
|
+
/* @__PURE__ */ jsxs("span", { className: "uix-visually-hidden", children: [
|
|
213
|
+
label,
|
|
214
|
+
": "
|
|
215
|
+
] }),
|
|
216
|
+
/* @__PURE__ */ jsx("span", { id: valueId, className: cx("uix-date-range-field__value", !text && "uix-date-range-field__value--empty"), children: text ?? words.placeholder })
|
|
217
|
+
]
|
|
218
|
+
}
|
|
219
|
+
),
|
|
220
|
+
/* @__PURE__ */ jsx(
|
|
221
|
+
Popover,
|
|
222
|
+
{
|
|
223
|
+
id: popoverId,
|
|
224
|
+
anchor: anchorRef,
|
|
225
|
+
placement,
|
|
226
|
+
className: "uix-date-range-field__popover",
|
|
227
|
+
role: "dialog",
|
|
228
|
+
"aria-label": label,
|
|
229
|
+
capHeight: true,
|
|
230
|
+
closeWhenAnchorHidden: true,
|
|
231
|
+
children: open && /* @__PURE__ */ jsx(
|
|
232
|
+
RangeCalendar,
|
|
233
|
+
{
|
|
234
|
+
...props,
|
|
235
|
+
className: "uix-date-range-picker--in-popover",
|
|
236
|
+
autoFocus: true,
|
|
237
|
+
onChange: (next) => {
|
|
238
|
+
onChange(next);
|
|
239
|
+
if (next.start && next.end) hide();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
]
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
}
|
|
146
249
|
export {
|
|
147
250
|
DEFAULT_DATE_RANGE_PICKER_LABELS,
|
|
148
251
|
DateRangePicker
|
|
@@ -17,14 +17,35 @@ interface EntityPickerLabels {
|
|
|
17
17
|
none: string;
|
|
18
18
|
/** Polite count after a search. `{count}`. */
|
|
19
19
|
results: string;
|
|
20
|
+
/**
|
|
21
|
+
* The last line of the list when the search found more than it returned (HAR-1648). Not an
|
|
22
|
+
* option: it cannot be chosen.
|
|
23
|
+
*/
|
|
24
|
+
more: string;
|
|
25
|
+
/** Polite count when there are more matches than shown. `{count}` is the number shown. */
|
|
26
|
+
resultsMore: string;
|
|
27
|
+
/** Shown in the open list while the query is shorter than `minQueryLength`. `{count}`. */
|
|
28
|
+
tooShort: string;
|
|
29
|
+
/** The same when `minQueryLength` is 1. */
|
|
30
|
+
typeToSearch: string;
|
|
20
31
|
}
|
|
21
32
|
declare const DEFAULT_ENTITY_PICKER_LABELS: EntityPickerLabels;
|
|
33
|
+
/** What `onSearch` may resolve to when it knows its list was cut off. */
|
|
34
|
+
interface EntityPickerSearchResult {
|
|
35
|
+
options: readonly SearchSuggestOption[];
|
|
36
|
+
/** There are more matches than `options` holds: the list ends with the `more` line. */
|
|
37
|
+
hasMore?: boolean;
|
|
38
|
+
}
|
|
22
39
|
interface EntityPickerProps {
|
|
23
40
|
/** The chosen record, or null. */
|
|
24
41
|
value: SearchSuggestOption | null;
|
|
25
42
|
onValueChange: (next: SearchSuggestOption | null) => void;
|
|
26
|
-
/**
|
|
27
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Finds records for the query; a rejection shows the error state with a Retry row. Resolve
|
|
45
|
+
* with the options, or with `{ options, hasMore: true }` when the list was cut off (a page of
|
|
46
|
+
* 20 out of many): the list then says that more match.
|
|
47
|
+
*/
|
|
48
|
+
onSearch: (query: string) => Promise<readonly SearchSuggestOption[] | EntityPickerSearchResult>;
|
|
28
49
|
/** The field's name (the visible label usually comes from a surrounding `Field`). */
|
|
29
50
|
label: string;
|
|
30
51
|
/** Id of the control, for `Field htmlFor`/`label for`. */
|
|
@@ -32,7 +53,10 @@ interface EntityPickerProps {
|
|
|
32
53
|
/** Hidden form value: the chosen record's id. */
|
|
33
54
|
name?: string;
|
|
34
55
|
placeholder?: string;
|
|
35
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Search only from this many characters. Default 0 (an empty query lists suggestions). Below
|
|
58
|
+
* it the open list shows the `tooShort` hint instead of nothing.
|
|
59
|
+
*/
|
|
36
60
|
minQueryLength?: number;
|
|
37
61
|
/** Debounce before searching, in ms. Default 250. */
|
|
38
62
|
delay?: number;
|
|
@@ -42,6 +66,7 @@ interface EntityPickerProps {
|
|
|
42
66
|
'aria-describedby'?: string;
|
|
43
67
|
/** How the chosen record shows in the field. Default: title, then the last breadcrumb. */
|
|
44
68
|
renderValue?: (option: SearchSuggestOption) => ReactNode;
|
|
69
|
+
/** Each word defaults to `UixLabelsProvider` `entityPicker`, then to English. */
|
|
45
70
|
labels?: Partial<EntityPickerLabels>;
|
|
46
71
|
className?: string;
|
|
47
72
|
}
|
|
@@ -53,4 +78,4 @@ interface EntityPickerProps {
|
|
|
53
78
|
*/
|
|
54
79
|
declare function EntityPicker({ value, onValueChange, onSearch, label, id, name, placeholder, minQueryLength, delay, disabled, invalid, 'aria-describedby': describedBy, renderValue, labels: labelOverrides, className, }: EntityPickerProps): react.JSX.Element;
|
|
55
80
|
|
|
56
|
-
export { DEFAULT_ENTITY_PICKER_LABELS, EntityPicker, type EntityPickerLabels, type EntityPickerProps };
|
|
81
|
+
export { DEFAULT_ENTITY_PICKER_LABELS, EntityPicker, type EntityPickerLabels, type EntityPickerProps, type EntityPickerSearchResult };
|
|
@@ -3,6 +3,7 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
import { cx } from "../cx.js";
|
|
5
5
|
import { fillLabel } from "../fill-label.js";
|
|
6
|
+
import { useUixLabels } from "../labels-context.js";
|
|
6
7
|
import { SearchSuggest } from "./SearchSuggest.js";
|
|
7
8
|
const DEFAULT_ENTITY_PICKER_LABELS = {
|
|
8
9
|
search: "Search",
|
|
@@ -13,7 +14,11 @@ const DEFAULT_ENTITY_PICKER_LABELS = {
|
|
|
13
14
|
clear: "Clear {title}",
|
|
14
15
|
change: "{label}: {title}. Change",
|
|
15
16
|
none: "Choose\u2026",
|
|
16
|
-
results: "{count} results"
|
|
17
|
+
results: "{count} results",
|
|
18
|
+
more: "More matches. Keep typing to narrow the search.",
|
|
19
|
+
resultsMore: "{count} results shown, more match. Keep typing to narrow the search.",
|
|
20
|
+
tooShort: "Type at least {count} characters.",
|
|
21
|
+
typeToSearch: "Type to search."
|
|
17
22
|
};
|
|
18
23
|
function EntityPicker({
|
|
19
24
|
value,
|
|
@@ -32,10 +37,11 @@ function EntityPicker({
|
|
|
32
37
|
labels: labelOverrides,
|
|
33
38
|
className
|
|
34
39
|
}) {
|
|
35
|
-
const labels = { ...DEFAULT_ENTITY_PICKER_LABELS, ...labelOverrides };
|
|
40
|
+
const labels = { ...DEFAULT_ENTITY_PICKER_LABELS, ...useUixLabels().entityPicker, ...labelOverrides };
|
|
36
41
|
const [editing, setEditing] = useState(value == null);
|
|
37
42
|
const [query, setQuery] = useState("");
|
|
38
43
|
const [results, setResults] = useState([]);
|
|
44
|
+
const [hasMore, setHasMore] = useState(false);
|
|
39
45
|
const [status, setStatus] = useState("idle");
|
|
40
46
|
const [attempt, setAttempt] = useState(0);
|
|
41
47
|
const latest = useRef(0);
|
|
@@ -52,6 +58,7 @@ function EntityPicker({
|
|
|
52
58
|
if (q.length < minQueryLength) {
|
|
53
59
|
setStatus("idle");
|
|
54
60
|
setResults([]);
|
|
61
|
+
setHasMore(false);
|
|
55
62
|
return;
|
|
56
63
|
}
|
|
57
64
|
const run = ++latest.current;
|
|
@@ -59,14 +66,16 @@ function EntityPicker({
|
|
|
59
66
|
setStatus("loading");
|
|
60
67
|
onSearch(q).then(
|
|
61
68
|
(found) => {
|
|
62
|
-
if (run
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
if (run !== latest.current) return;
|
|
70
|
+
const list = "options" in found ? found.options : found;
|
|
71
|
+
setResults(list);
|
|
72
|
+
setHasMore("options" in found && !!found.hasMore && list.length > 0);
|
|
73
|
+
setStatus("done");
|
|
66
74
|
},
|
|
67
75
|
() => {
|
|
68
76
|
if (run === latest.current) {
|
|
69
77
|
setResults([]);
|
|
78
|
+
setHasMore(false);
|
|
70
79
|
setStatus("error");
|
|
71
80
|
}
|
|
72
81
|
}
|
|
@@ -95,6 +104,7 @@ function EntityPicker({
|
|
|
95
104
|
setEditing(true);
|
|
96
105
|
};
|
|
97
106
|
const hidden = name ? /* @__PURE__ */ jsx("input", { type: "hidden", name, value: value?.id ?? "" }) : null;
|
|
107
|
+
const tooShort = !disabled && query.trim().length < minQueryLength;
|
|
98
108
|
if (!editing && value) {
|
|
99
109
|
const shown = renderValue?.(value) ?? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
100
110
|
/* @__PURE__ */ jsx("span", { className: "uix-entity-picker__title", children: value.title }),
|
|
@@ -150,10 +160,12 @@ function EntityPicker({
|
|
|
150
160
|
placeholder: placeholder ?? labels.none,
|
|
151
161
|
loading: status === "loading",
|
|
152
162
|
loadingLabel: labels.loading,
|
|
153
|
-
empty: labels.empty,
|
|
163
|
+
empty: tooShort ? void 0 : labels.empty,
|
|
164
|
+
idle: tooShort ? fillLabel(minQueryLength === 1 ? labels.typeToSearch : labels.tooShort, { count: minQueryLength }) : void 0,
|
|
165
|
+
note: status === "done" && hasMore ? labels.more : void 0,
|
|
154
166
|
error: status === "error" ? labels.error : void 0,
|
|
155
167
|
footer: status === "error" ? { label: labels.retry, onSelect: () => setAttempt((n) => n + 1) } : void 0,
|
|
156
|
-
status: status === "done" ? fillLabel(labels.results, { count: results.length }) : void 0,
|
|
168
|
+
status: status === "done" ? fillLabel(hasMore ? labels.resultsMore : labels.results, { count: results.length }) : void 0,
|
|
157
169
|
strategy: "fixed"
|
|
158
170
|
}
|
|
159
171
|
),
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/** Every word `InlineEdit` renders or announces. `{label}` is the field's name. */
|
|
5
|
+
interface InlineEditLabels {
|
|
6
|
+
/** Added to the view button's name after the value: "…, Edit {label}". */
|
|
7
|
+
edit: string;
|
|
8
|
+
save: string;
|
|
9
|
+
cancel: string;
|
|
10
|
+
/** Polite status while `onSave` runs. */
|
|
11
|
+
saving: string;
|
|
12
|
+
/** Polite status after a save. */
|
|
13
|
+
saved: string;
|
|
14
|
+
/** Shown when `onSave` rejects without a message of its own. */
|
|
15
|
+
failed: string;
|
|
16
|
+
/** What the view shows when the value is empty and no `placeholder` was given. */
|
|
17
|
+
empty: string;
|
|
18
|
+
}
|
|
19
|
+
declare const DEFAULT_INLINE_EDIT_LABELS: InlineEditLabels;
|
|
20
|
+
/** What a custom editor receives. Spread the field props onto your control. */
|
|
21
|
+
interface InlineEditEditorProps {
|
|
22
|
+
/** The draft. */
|
|
23
|
+
value: string;
|
|
24
|
+
onChange: (next: string) => void;
|
|
25
|
+
/** Save the draft (what Enter does in the default editor). */
|
|
26
|
+
save: () => void;
|
|
27
|
+
cancel: () => void;
|
|
28
|
+
/** `id`, the accessible name, the error wiring and `disabled` while saving. */
|
|
29
|
+
field: {
|
|
30
|
+
id: string;
|
|
31
|
+
'aria-label': string;
|
|
32
|
+
'aria-describedby': string | undefined;
|
|
33
|
+
'aria-invalid': true | undefined;
|
|
34
|
+
disabled: boolean;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
interface InlineEditProps {
|
|
38
|
+
/** The saved value. */
|
|
39
|
+
value: string;
|
|
40
|
+
/**
|
|
41
|
+
* Save the new value. Return a promise to show the pending state; a rejection keeps the
|
|
42
|
+
* editor open with the error under it (the rejection's message, else `labels.failed`).
|
|
43
|
+
*/
|
|
44
|
+
onSave: (next: string) => void | Promise<void>;
|
|
45
|
+
/** The field's name: "Title", "Due date". It names the edit button and the editor. */
|
|
46
|
+
label: string;
|
|
47
|
+
/** How the saved value shows. Default: the text itself. */
|
|
48
|
+
renderView?: (value: string) => ReactNode;
|
|
49
|
+
/**
|
|
50
|
+
* A custom editor: a `Select`, a `Combobox`, a date field. Spread `field` onto the control.
|
|
51
|
+
* Default: an `Input`, or a `Textarea` with `multiline`.
|
|
52
|
+
*/
|
|
53
|
+
renderEditor?: (props: InlineEditEditorProps) => ReactNode;
|
|
54
|
+
/** Edit in a textarea: Enter adds a line, Ctrl/⌘+Enter saves. */
|
|
55
|
+
multiline?: boolean;
|
|
56
|
+
/** Return a message to refuse the draft; nothing is saved while it returns one. */
|
|
57
|
+
validate?: (next: string) => ReactNode | undefined;
|
|
58
|
+
/** Shown in the view while the value is empty, e.g. "Add a description". */
|
|
59
|
+
placeholder?: ReactNode;
|
|
60
|
+
/** Read-only: the value shows as plain text, with no edit button. */
|
|
61
|
+
disabled?: boolean;
|
|
62
|
+
/** Each word defaults to `UixLabelsProvider` `inlineEdit`, then to English. */
|
|
63
|
+
labels?: Partial<InlineEditLabels>;
|
|
64
|
+
className?: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A value that is edited in place (HAR-1381; TENSOR C16 `editable-record.tsx`): the value is
|
|
68
|
+
* a button that says what it edits; activating it swaps in an editor with Save and Cancel.
|
|
69
|
+
* Enter saves and Escape cancels; while `onSave` runs the editor is busy; a failed save keeps
|
|
70
|
+
* the draft and shows why; afterwards focus is back on the value. Nothing is saved on blur.
|
|
71
|
+
*/
|
|
72
|
+
declare function InlineEdit({ value, onSave, label, renderView, renderEditor, multiline, validate, placeholder, disabled, labels: labelOverrides, className, }: InlineEditProps): react.JSX.Element;
|
|
73
|
+
|
|
74
|
+
export { DEFAULT_INLINE_EDIT_LABELS, InlineEdit, type InlineEditEditorProps, type InlineEditLabels, type InlineEditProps };
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import { cx } from "../cx.js";
|
|
5
|
+
import { fillLabel } from "../fill-label.js";
|
|
6
|
+
import { useUixLabels } from "../labels-context.js";
|
|
7
|
+
import { PencilIcon } from "../icons/components.js";
|
|
8
|
+
import { Button } from "./Button.js";
|
|
9
|
+
import { Input } from "./Input.js";
|
|
10
|
+
import { Textarea } from "./Textarea.js";
|
|
11
|
+
const DEFAULT_INLINE_EDIT_LABELS = {
|
|
12
|
+
edit: "Edit {label}",
|
|
13
|
+
save: "Save",
|
|
14
|
+
cancel: "Cancel",
|
|
15
|
+
saving: "Saving\u2026",
|
|
16
|
+
saved: "Saved",
|
|
17
|
+
failed: "Could not save.",
|
|
18
|
+
empty: "Not set"
|
|
19
|
+
};
|
|
20
|
+
function InlineEdit({
|
|
21
|
+
value,
|
|
22
|
+
onSave,
|
|
23
|
+
label,
|
|
24
|
+
renderView,
|
|
25
|
+
renderEditor,
|
|
26
|
+
multiline = false,
|
|
27
|
+
validate,
|
|
28
|
+
placeholder,
|
|
29
|
+
disabled = false,
|
|
30
|
+
labels: labelOverrides,
|
|
31
|
+
className
|
|
32
|
+
}) {
|
|
33
|
+
const labels = { ...DEFAULT_INLINE_EDIT_LABELS, ...useUixLabels().inlineEdit, ...labelOverrides };
|
|
34
|
+
const id = useId();
|
|
35
|
+
const errorId = `${id}-error`;
|
|
36
|
+
const [editing, setEditing] = useState(false);
|
|
37
|
+
const [draft, setDraft] = useState(value);
|
|
38
|
+
const [pending, setPending] = useState(false);
|
|
39
|
+
const [error, setError] = useState();
|
|
40
|
+
const [status, setStatus] = useState("");
|
|
41
|
+
const viewRef = useRef(null);
|
|
42
|
+
const editorRef = useRef(null);
|
|
43
|
+
const focusNext = useRef(null);
|
|
44
|
+
const mounted = useRef(true);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
mounted.current = true;
|
|
47
|
+
return () => {
|
|
48
|
+
mounted.current = false;
|
|
49
|
+
};
|
|
50
|
+
}, []);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const target = focusNext.current;
|
|
53
|
+
if (!target) return;
|
|
54
|
+
focusNext.current = null;
|
|
55
|
+
if (target === "view") {
|
|
56
|
+
viewRef.current?.focus();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const control = editorRef.current?.querySelector("input, textarea, select, button, [tabindex]");
|
|
60
|
+
control?.focus();
|
|
61
|
+
if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) control.select();
|
|
62
|
+
}, [editing]);
|
|
63
|
+
const start = () => {
|
|
64
|
+
setDraft(value);
|
|
65
|
+
setError(void 0);
|
|
66
|
+
setStatus("");
|
|
67
|
+
focusNext.current = "editor";
|
|
68
|
+
setEditing(true);
|
|
69
|
+
};
|
|
70
|
+
const close = () => {
|
|
71
|
+
focusNext.current = "view";
|
|
72
|
+
setEditing(false);
|
|
73
|
+
};
|
|
74
|
+
const cancel = () => {
|
|
75
|
+
if (pending) return;
|
|
76
|
+
setError(void 0);
|
|
77
|
+
close();
|
|
78
|
+
};
|
|
79
|
+
const save = () => {
|
|
80
|
+
if (pending) return;
|
|
81
|
+
const issue = validate?.(draft);
|
|
82
|
+
if (issue != null) {
|
|
83
|
+
setError(issue);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (draft === value) {
|
|
87
|
+
close();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
setError(void 0);
|
|
91
|
+
let result;
|
|
92
|
+
try {
|
|
93
|
+
result = onSave(draft);
|
|
94
|
+
} catch (thrown) {
|
|
95
|
+
setError(thrown instanceof Error && thrown.message ? thrown.message : labels.failed);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (!result || typeof result.then !== "function") {
|
|
99
|
+
setStatus(labels.saved);
|
|
100
|
+
close();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
setPending(true);
|
|
104
|
+
setStatus(labels.saving);
|
|
105
|
+
result.then(
|
|
106
|
+
() => {
|
|
107
|
+
if (!mounted.current) return;
|
|
108
|
+
setPending(false);
|
|
109
|
+
setStatus(labels.saved);
|
|
110
|
+
close();
|
|
111
|
+
},
|
|
112
|
+
(reason) => {
|
|
113
|
+
if (!mounted.current) return;
|
|
114
|
+
setPending(false);
|
|
115
|
+
setStatus("");
|
|
116
|
+
setError(typeof reason === "string" && reason ? reason : reason instanceof Error && reason.message ? reason.message : labels.failed);
|
|
117
|
+
focusNext.current = null;
|
|
118
|
+
editorRef.current?.querySelector("input, textarea, select")?.focus();
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
const onEditorKeyDown = (event) => {
|
|
123
|
+
if (event.key === "Escape") {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
event.stopPropagation();
|
|
126
|
+
cancel();
|
|
127
|
+
} else if (event.key === "Enter" && !event.defaultPrevented) {
|
|
128
|
+
const inField = event.target.matches("input, textarea");
|
|
129
|
+
if (!inField) return;
|
|
130
|
+
const isTextarea = event.target.tagName === "TEXTAREA";
|
|
131
|
+
if (isTextarea && !(event.ctrlKey || event.metaKey)) return;
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
save();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const liveRegion = /* @__PURE__ */ jsx("span", { className: "uix-visually-hidden", role: "status", "aria-live": "polite", children: status });
|
|
137
|
+
const shown = value === "" ? /* @__PURE__ */ jsx("span", { className: "uix-inline-edit__placeholder", children: placeholder ?? labels.empty }) : renderView?.(value) ?? value;
|
|
138
|
+
if (disabled) {
|
|
139
|
+
return /* @__PURE__ */ jsx("span", { className: cx("uix-inline-edit", "uix-inline-edit--readonly", className), children: /* @__PURE__ */ jsx("span", { className: "uix-inline-edit__value", children: shown }) });
|
|
140
|
+
}
|
|
141
|
+
if (!editing) {
|
|
142
|
+
return /* @__PURE__ */ jsxs("span", { className: cx("uix-inline-edit", className), children: [
|
|
143
|
+
/* @__PURE__ */ jsxs("button", { ref: viewRef, type: "button", className: "uix-inline-edit__view", onClick: start, children: [
|
|
144
|
+
/* @__PURE__ */ jsx("span", { className: "uix-inline-edit__value", children: shown }),
|
|
145
|
+
/* @__PURE__ */ jsxs("span", { className: "uix-visually-hidden", children: [
|
|
146
|
+
", ",
|
|
147
|
+
fillLabel(labels.edit, { label })
|
|
148
|
+
] }),
|
|
149
|
+
/* @__PURE__ */ jsx("span", { className: "uix-inline-edit__icon", "aria-hidden": "true", children: /* @__PURE__ */ jsx(PencilIcon, { size: "sm" }) })
|
|
150
|
+
] }),
|
|
151
|
+
liveRegion
|
|
152
|
+
] });
|
|
153
|
+
}
|
|
154
|
+
const field = {
|
|
155
|
+
id,
|
|
156
|
+
"aria-label": label,
|
|
157
|
+
"aria-describedby": error != null ? errorId : void 0,
|
|
158
|
+
"aria-invalid": error != null ? true : void 0,
|
|
159
|
+
disabled: pending
|
|
160
|
+
};
|
|
161
|
+
return /* @__PURE__ */ jsxs("span", { className: cx("uix-inline-edit", "uix-inline-edit--editing", className), children: [
|
|
162
|
+
/* @__PURE__ */ jsxs("div", { ref: editorRef, className: "uix-inline-edit__editor", role: "group", "aria-label": fillLabel(labels.edit, { label }), "aria-busy": pending || void 0, onKeyDown: onEditorKeyDown, children: [
|
|
163
|
+
/* @__PURE__ */ jsx("div", { className: "uix-inline-edit__control", children: renderEditor ? renderEditor({ value: draft, onChange: setDraft, save, cancel, field }) : multiline ? /* @__PURE__ */ jsx(Textarea, { ...field, rows: 3, value: draft, invalid: error != null, onChange: (event) => setDraft(event.currentTarget.value) }) : /* @__PURE__ */ jsx(Input, { ...field, size: "sm", value: draft, invalid: error != null, onChange: (event) => setDraft(event.currentTarget.value) }) }),
|
|
164
|
+
/* @__PURE__ */ jsxs("span", { className: "uix-inline-edit__actions", children: [
|
|
165
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "primary", loading: pending, onClick: save, children: labels.save }),
|
|
166
|
+
/* @__PURE__ */ jsx(Button, { type: "button", size: "sm", disabled: pending, onClick: cancel, children: labels.cancel })
|
|
167
|
+
] }),
|
|
168
|
+
error != null && /* @__PURE__ */ jsx("div", { id: errorId, className: "uix-field__error uix-inline-edit__error", role: "alert", children: error })
|
|
169
|
+
] }),
|
|
170
|
+
liveRegion
|
|
171
|
+
] });
|
|
172
|
+
}
|
|
173
|
+
export {
|
|
174
|
+
DEFAULT_INLINE_EDIT_LABELS,
|
|
175
|
+
InlineEdit
|
|
176
|
+
};
|
|
@@ -5,12 +5,33 @@ interface RadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>
|
|
|
5
5
|
label?: ReactNode;
|
|
6
6
|
}
|
|
7
7
|
declare const Radio: react.ForwardRefExoticComponent<RadioProps & react.RefAttributes<HTMLInputElement>>;
|
|
8
|
+
interface RadioCardProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'title'> {
|
|
9
|
+
/** The choice's name. */
|
|
10
|
+
title: ReactNode;
|
|
11
|
+
/** One or two lines that explain the choice. */
|
|
12
|
+
description?: ReactNode;
|
|
13
|
+
/** A preview, an illustration or an icon above the text (decorative: the title names the choice). */
|
|
14
|
+
media?: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A radio as a card (HAR-1373; TENSOR C17/B14): for a choice whose options need a preview or
|
|
18
|
+
* a description (appearance, plan, mode). It is a native radio inside a label, so the group's
|
|
19
|
+
* keyboard, form value and `required` work as for `Radio`. The selected card shows a check
|
|
20
|
+
* mark and a stronger border, never colour alone. Put the cards in
|
|
21
|
+
* `<RadioGroup variant="card" label="…">` and give them one `name`.
|
|
22
|
+
*/
|
|
23
|
+
declare const RadioCard: react.ForwardRefExoticComponent<RadioCardProps & react.RefAttributes<HTMLInputElement>>;
|
|
8
24
|
interface RadioGroupProps {
|
|
9
25
|
children: ReactNode;
|
|
10
26
|
className?: string;
|
|
11
27
|
/** Group name; when set the group renders as fieldset/legend so it's announced with each radio. */
|
|
12
28
|
label?: ReactNode;
|
|
29
|
+
/**
|
|
30
|
+
* `'list'` (default): radios in a column. `'card'`: a grid of `RadioCard`s that wraps to one
|
|
31
|
+
* column on a phone.
|
|
32
|
+
*/
|
|
33
|
+
variant?: 'list' | 'card';
|
|
13
34
|
}
|
|
14
|
-
declare function RadioGroup({ children, className, label }: RadioGroupProps): react.JSX.Element;
|
|
35
|
+
declare function RadioGroup({ children, className, label, variant }: RadioGroupProps): react.JSX.Element;
|
|
15
36
|
|
|
16
|
-
export { Radio, RadioGroup, type RadioGroupProps, type RadioProps };
|
|
37
|
+
export { Radio, RadioCard, type RadioCardProps, RadioGroup, type RadioGroupProps, type RadioProps };
|
package/dist/components/Radio.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { forwardRef } from "react";
|
|
3
3
|
import { cx } from "../cx.js";
|
|
4
|
+
import { CheckIcon } from "../icons/components.js";
|
|
4
5
|
const Radio = forwardRef(
|
|
5
6
|
({ label, className, ...props }, ref) => /* @__PURE__ */ jsxs("label", { className: cx("uix-radio", className), children: [
|
|
6
7
|
/* @__PURE__ */ jsx("input", { type: "radio", ref, ...props }),
|
|
@@ -9,14 +10,28 @@ const Radio = forwardRef(
|
|
|
9
10
|
] })
|
|
10
11
|
);
|
|
11
12
|
Radio.displayName = "Radio";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const RadioCard = forwardRef(
|
|
14
|
+
({ title, description, media, className, ...props }, ref) => /* @__PURE__ */ jsxs("label", { className: cx("uix-radio-card", className), children: [
|
|
15
|
+
/* @__PURE__ */ jsx("input", { type: "radio", ref, ...props }),
|
|
16
|
+
media != null && /* @__PURE__ */ jsx("span", { className: "uix-radio-card__media", "aria-hidden": "true", children: media }),
|
|
17
|
+
/* @__PURE__ */ jsxs("span", { className: "uix-radio-card__body", children: [
|
|
18
|
+
/* @__PURE__ */ jsx("span", { className: "uix-radio-card__title", children: title }),
|
|
19
|
+
description != null && /* @__PURE__ */ jsx("span", { className: "uix-radio-card__desc", children: description })
|
|
20
|
+
] }),
|
|
21
|
+
/* @__PURE__ */ jsx("span", { className: "uix-radio-card__check", "aria-hidden": "true", children: /* @__PURE__ */ jsx(CheckIcon, { size: "sm" }) })
|
|
22
|
+
] })
|
|
23
|
+
);
|
|
24
|
+
RadioCard.displayName = "RadioCard";
|
|
25
|
+
function RadioGroup({ children, className, label, variant = "list" }) {
|
|
26
|
+
const classes = cx("uix-radio-group", variant === "card" && "uix-radio-group--cards", className);
|
|
27
|
+
if (label == null) return /* @__PURE__ */ jsx("div", { className: classes, children });
|
|
28
|
+
return /* @__PURE__ */ jsxs("fieldset", { className: classes, children: [
|
|
15
29
|
/* @__PURE__ */ jsx("legend", { className: "uix-radio-group__legend", children: label }),
|
|
16
30
|
children
|
|
17
31
|
] });
|
|
18
32
|
}
|
|
19
33
|
export {
|
|
20
34
|
Radio,
|
|
35
|
+
RadioCard,
|
|
21
36
|
RadioGroup
|
|
22
37
|
};
|