@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.
Files changed (35) hide show
  1. package/README.md +141 -15
  2. package/dist/components/Avatar.d.ts +26 -4
  3. package/dist/components/Avatar.js +26 -3
  4. package/dist/components/CommandPalette.d.ts +6 -1
  5. package/dist/components/CommandPalette.js +9 -3
  6. package/dist/components/DateFieldParts.d.ts +61 -0
  7. package/dist/components/DateFieldParts.js +195 -0
  8. package/dist/components/DatePicker.d.ts +129 -0
  9. package/dist/components/DatePicker.js +340 -0
  10. package/dist/components/DateRangePicker.d.ts +34 -3
  11. package/dist/components/DateRangePicker.js +120 -17
  12. package/dist/components/EntityPicker.d.ts +29 -4
  13. package/dist/components/EntityPicker.js +20 -8
  14. package/dist/components/InlineEdit.d.ts +74 -0
  15. package/dist/components/InlineEdit.js +176 -0
  16. package/dist/components/Radio.d.ts +23 -2
  17. package/dist/components/Radio.js +18 -3
  18. package/dist/components/SchedulingTimeline.d.ts +130 -15
  19. package/dist/components/SchedulingTimeline.js +765 -141
  20. package/dist/components/SearchSuggest.d.ts +12 -1
  21. package/dist/components/SearchSuggest.js +7 -2
  22. package/dist/components/Tree.d.ts +30 -2
  23. package/dist/components/Tree.js +135 -10
  24. package/dist/date-field-model.d.ts +40 -0
  25. package/dist/date-field-model.js +79 -0
  26. package/dist/index.cjs +3854 -2068
  27. package/dist/index.d.cts +808 -262
  28. package/dist/index.d.ts +9 -6
  29. package/dist/index.js +40 -4
  30. package/dist/labels-context.d.ts +14 -1
  31. package/dist/scheduling-timeline-model.d.ts +61 -11
  32. package/dist/scheduling-timeline-model.js +85 -26
  33. package/dist/scheduling-timeline-rows.d.ts +40 -0
  34. package/dist/scheduling-timeline-rows.js +42 -0
  35. 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
- const weekdayNames = (locale) => {
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
- labels: labelOverrides,
42
- today
43
+ words: labels,
44
+ today,
45
+ weekStartsOn = 1,
46
+ autoFocus = false
43
47
  }) {
44
48
  const id = useId();
45
- const uixLabels = useUixLabels();
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(false);
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 mondayIndex = (weekday + 6) % 7;
97
- focusDate(addCalendarDays(date, event.key === "Home" ? -mondayIndex : 6 - mondayIndex));
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() + 6) % 7 }, (_, index) => /* @__PURE__ */ jsx("span", { "aria-hidden": "true" }, `blank-${index}`)),
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
- /** Finds records for the query; a rejection shows the error state with a Retry row. */
27
- onSearch: (query: string) => Promise<readonly SearchSuggestOption[]>;
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
- /** Search only from this many characters. Default 0 (an empty query lists suggestions). */
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 === latest.current) {
63
- setResults(found);
64
- setStatus("done");
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 };
@@ -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
- function RadioGroup({ children, className, label }) {
13
- if (label == null) return /* @__PURE__ */ jsx("div", { className: cx("uix-radio-group", className), children });
14
- return /* @__PURE__ */ jsxs("fieldset", { className: cx("uix-radio-group", className), children: [
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
  };