@tensor_1/react 2.24.0 → 2.26.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 CHANGED
@@ -92,7 +92,9 @@ Markdown cannot represent everything a user can type. When the editor re-seriali
92
92
  - Reference definitions (`[id]: url`) are kept. If their surrounding block is deleted, they move to the end.
93
93
 
94
94
  Editor props: `features` (`full` / `comment` / `template`), `headingLevels` (limits the toolbar and input rules;
95
- existing headings at other levels keep their level), `onUploadImage` (enables the image button and image paste/drop),
95
+ existing headings at other levels keep their level), `onUploadImage` (enables the image button and image paste/drop in
96
+ every preset), `imagesUnavailableReason` (status-line text when an image is pasted or dropped while images are off;
97
+ default label `imagesUnsupported`, "Images can't be added here."; a paste that also carries text stays a text paste),
96
98
  `isSafeUrl`, `resolveImageSrc` (its return value is used verbatim), `onSubmitShortcut` (Ctrl/Cmd+Enter), `emoji`,
97
99
  `emojiImageBaseUrl`, `maxLength` (counter; over the limit sets `aria-invalid`), `placeholder`, `disabled`, `readOnly`,
98
100
  `labels` (every string, placeholders `{count}` `{max}`), `emojiPickerLabels`, `emojiLocale`, `id`, `name` (adds a
@@ -226,3 +228,32 @@ Radial mode still shortens long labels to fit its ring, but every node carries i
226
228
  ```
227
229
 
228
230
  Ships **ESM + CJS + types**, with per-file `"use client"` so it's safe under React Server Components. Part of the **[UIx v2 styleguide](https://github.com/COx-ORG-H/UIx)**.
231
+
232
+ ### Drawer closes on a backdrop click
233
+
234
+ `Drawer` closes on a click on the dimmed backdrop, as well as on Escape and its close button, and calls the same
235
+ `onClose` (TENSOR HAR-547). `Peek` already did; both now share one handler. On a drawer that holds unsaved form input,
236
+ pass `dismissOnBackdrop={false}` so a stray click can't discard it; Escape and the close button still close. On a
237
+ phone the panel stops 2em + 6px short of the left edge (the browser's modal-dialog `max-width`), and a tap on that
238
+ strip closes it too.
239
+
240
+ ```tsx
241
+ <Drawer open={open} onClose={close} title="Edit user" dismissOnBackdrop={!dirty}>…</Drawer>
242
+ ```
243
+
244
+ ### InfoTip and `help` slots
245
+
246
+ A small ? next to a title or label opens a plain-text explanation. Use it for what a page, section or field *means*. Keep instructions the user needs while typing ("8+ characters", "ISO date") visible in `hint`.
247
+
248
+ ```tsx
249
+ <PageHeader title={t('incidents')} help={t('incidents.help')} helpLabel={t('aboutIncidents')} />
250
+ <Card title="Open work" titleAs="h2" help={help.openWork}>…</Card>
251
+ <SectionHead title="Latest news" help={help.news} />
252
+ <Field label="Impact" required help={help.impact}><Input /></Field>
253
+ <InfoTip content={text} label="About: Impact" />
254
+ ```
255
+
256
+ - **`help?: string | null`** on `PageHeader`, `Card`, `SectionHead` and `Field`. Empty, whitespace-only or `null` renders nothing, so pass the resolved string without a guard. Blank lines become paragraphs. The text is always plain, never HTML.
257
+ - **`helpLabel`** is the ? button's accessible name. It defaults to `"About: <title>"` (English), so pass a translated one.
258
+ - **Placement:** the ? is a fixed 9 px glyph, superscript at the top right of the title or label, whatever the text size. It sits beside the heading or `<label>`, never inside it, so the heading's name stays the title and clicking a field label still focuses the input.
259
+ - **Behaviour:** hover opens it after about 300 ms, keyboard focus opens it at once, and a click or tap keeps it open. Esc closes it and keeps focus on the ?, and a press outside also closes it. The panel is the button's `aria-describedby`, so screen readers announce it on focus. It uses `popover="manual"`, so it never closes another open popover.
@@ -6,11 +6,18 @@ interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
6
6
  /** Element for the title — pick a real heading to slot the card into the page outline (UIX-A11Y-4). Default `div` keeps legacy rendering. */
7
7
  titleAs?: 'h2' | 'h3' | 'h4' | 'div';
8
8
  subtitle?: ReactNode;
9
+ /**
10
+ * Plain-text help behind a ? button after the title. Blank lines separate paragraphs;
11
+ * empty, whitespace-only or `null` renders no button.
12
+ */
13
+ help?: string | null;
14
+ /** Accessible name of the ? button. Default `"About: <title>"` when title is text; pass a localised one. */
15
+ helpLabel?: string;
9
16
  headerAction?: ReactNode;
10
17
  footer?: ReactNode;
11
18
  children?: ReactNode;
12
19
  }
13
- declare function Card({ title, titleAs: TitleTag, subtitle, headerAction, footer, children, className, ...props }: CardProps): react.JSX.Element;
20
+ declare function Card({ title, titleAs: TitleTag, subtitle, help, helpLabel, headerAction, footer, children, className, ...props }: CardProps): react.JSX.Element;
14
21
  interface CardLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'title'> {
15
22
  title?: ReactNode;
16
23
  subtitle?: ReactNode;
@@ -1,11 +1,17 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { cx } from "../cx.js";
3
- function Card({ title, titleAs: TitleTag = "div", subtitle, headerAction, footer, children, className, ...props }) {
3
+ import { InfoTip } from "./InfoTip.js";
4
+ import { hasHelp, helpLabelFor } from "../info-tip-model.js";
5
+ function Card({ title, titleAs: TitleTag = "div", subtitle, help, helpLabel, headerAction, footer, children, className, ...props }) {
4
6
  const hasHeader = title != null || subtitle != null || headerAction != null;
7
+ const heading = title ? /* @__PURE__ */ jsx(TitleTag, { className: "uix-card__title", children: title }) : null;
5
8
  return /* @__PURE__ */ jsxs("div", { className: cx("uix-card", className), ...props, children: [
6
9
  hasHeader && /* @__PURE__ */ jsxs("div", { className: "uix-card__header", children: [
7
10
  /* @__PURE__ */ jsxs("div", { style: { flex: 1 }, children: [
8
- title && /* @__PURE__ */ jsx(TitleTag, { className: "uix-card__title", children: title }),
11
+ heading && hasHelp(help) ? /* @__PURE__ */ jsxs("div", { className: "uix-card__title-row", children: [
12
+ heading,
13
+ /* @__PURE__ */ jsx(InfoTip, { content: help, label: helpLabel ?? helpLabelFor(title) })
14
+ ] }) : heading,
9
15
  subtitle && /* @__PURE__ */ jsx("div", { className: "uix-card__subtitle", children: subtitle })
10
16
  ] }),
11
17
  headerAction
@@ -6,10 +6,16 @@ interface DrawerProps extends Omit<HTMLAttributes<HTMLDialogElement>, 'title'> {
6
6
  closeLabel?: string;
7
7
  open: boolean;
8
8
  onClose?: () => void;
9
+ /**
10
+ * A click on the dimmed backdrop calls `onClose`, like Escape and the close button
11
+ * (TENSOR HAR-547). Pass `false` on a drawer that holds unsaved form input, so a stray click
12
+ * can't discard it; Escape and the close button still close. Default `true`.
13
+ */
14
+ dismissOnBackdrop?: boolean;
9
15
  title?: ReactNode;
10
16
  children?: ReactNode;
11
17
  footer?: ReactNode;
12
18
  }
13
- declare function Drawer({ open, onClose, title, children, footer, closeLabel, className, ...rest }: DrawerProps): react.JSX.Element;
19
+ declare function Drawer({ open, onClose, dismissOnBackdrop, title, children, footer, closeLabel, className, onClick, ...rest }: DrawerProps): react.JSX.Element;
14
20
 
15
21
  export { Drawer, type DrawerProps };
@@ -3,11 +3,12 @@ import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useId } from "react";
4
4
  import { cx } from "../cx.js";
5
5
  import { useDialog } from "../hooks/useDialog.js";
6
+ import { backdropDismiss } from "../hooks/backdropDismiss.js";
6
7
  const CloseIcon = () => /* @__PURE__ */ jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M4 4l8 8M12 4l-8 8" }) });
7
- function Drawer({ open, onClose, title, children, footer, closeLabel = "Close drawer", className, ...rest }) {
8
+ function Drawer({ open, onClose, dismissOnBackdrop = true, title, children, footer, closeLabel = "Close drawer", className, onClick, ...rest }) {
8
9
  const ref = useDialog(open, onClose);
9
10
  const titleId = useId();
10
- return /* @__PURE__ */ jsxs("dialog", { ref, className: cx("uix-drawer", className), "aria-labelledby": title ? titleId : void 0, ...rest, children: [
11
+ return /* @__PURE__ */ jsxs("dialog", { ref, className: cx("uix-drawer", className), "aria-labelledby": title ? titleId : void 0, ...rest, onClick: backdropDismiss(onClose, onClick, dismissOnBackdrop), children: [
11
12
  (title != null || onClose) && /* @__PURE__ */ jsxs("div", { className: "uix-drawer__header", children: [
12
13
  title && /* @__PURE__ */ jsx("h2", { id: titleId, style: { fontWeight: 600, fontSize: "var(--uix-text-h3)", fontFamily: "inherit", letterSpacing: "inherit", lineHeight: "inherit" }, children: title }),
13
14
  onClose && /* @__PURE__ */ jsx(
@@ -25,11 +25,18 @@ interface SectionHeadProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'>
25
25
  title: ReactNode;
26
26
  /** id for the `<h2>`, for `aria-labelledby` wiring on the enclosing section. */
27
27
  titleId?: string;
28
+ /**
29
+ * Plain-text help behind a ? button after the title. Blank lines separate paragraphs;
30
+ * empty, whitespace-only or `null` renders no button.
31
+ */
32
+ help?: string | null;
33
+ /** Accessible name of the ? button. Default `"About: <title>"` when title is text; pass a localised one. */
34
+ helpLabel?: string;
28
35
  /** Right-aligned slot — a `.uix-section-link` anchor, a button, a pill. */
29
36
  action?: ReactNode;
30
37
  }
31
- /** Section head — title row with an optional trailing action (`.uix-section-head`). */
32
- declare function SectionHead({ title, titleId, action, className, ...props }: SectionHeadProps): react.JSX.Element;
38
+ /** Section head — title row (+ optional `help` ?) with an optional trailing action (`.uix-section-head`). */
39
+ declare function SectionHead({ title, titleId, help, helpLabel, action, className, ...props }: SectionHeadProps): react.JSX.Element;
33
40
  interface NoticeQueueProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
34
41
  /** Leading icon slot. */
35
42
  icon?: ReactNode;
@@ -1,5 +1,7 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { cx } from "../cx.js";
3
+ import { InfoTip } from "./InfoTip.js";
4
+ import { hasHelp, helpLabelFor } from "../info-tip-model.js";
3
5
  function PageIntro({
4
6
  kicker,
5
7
  title,
@@ -26,9 +28,13 @@ function PageIntro({
26
28
  children
27
29
  ] });
28
30
  }
29
- function SectionHead({ title, titleId, action, className, ...props }) {
31
+ function SectionHead({ title, titleId, help, helpLabel, action, className, ...props }) {
32
+ const heading = /* @__PURE__ */ jsx("h2", { className: "uix-section-title", id: titleId, children: title });
30
33
  return /* @__PURE__ */ jsxs("div", { className: cx("uix-section-head", className), ...props, children: [
31
- /* @__PURE__ */ jsx("h2", { className: "uix-section-title", id: titleId, children: title }),
34
+ hasHelp(help) ? /* @__PURE__ */ jsxs("div", { className: "uix-section-head__title-row", children: [
35
+ heading,
36
+ /* @__PURE__ */ jsx(InfoTip, { content: help, label: helpLabel ?? helpLabelFor(title) })
37
+ ] }) : heading,
32
38
  action
33
39
  ] });
34
40
  }
@@ -7,10 +7,18 @@ interface FieldProps {
7
7
  error?: string;
8
8
  success?: string;
9
9
  required?: boolean;
10
+ /**
11
+ * Plain-text help behind a ? button after the label (before the required marker). Blank
12
+ * lines separate paragraphs; empty, whitespace-only or `null` renders no button. Needs `label`.
13
+ * Keep format or validation instructions in `hint`, where they stay visible.
14
+ */
15
+ help?: string | null;
16
+ /** Accessible name of the ? button. Default `"About: <label>"`; pass a localised one. */
17
+ helpLabel?: string;
10
18
  children: ReactNode;
11
19
  className?: string;
12
20
  htmlFor?: string;
13
21
  }
14
- declare function Field({ label, hint, error, success, required, children, className, htmlFor }: FieldProps): react.JSX.Element;
22
+ declare function Field({ label, hint, error, success, required, help, helpLabel, children, className, htmlFor }: FieldProps): react.JSX.Element;
15
23
 
16
24
  export { Field, type FieldProps };
@@ -1,7 +1,9 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { cloneElement, isValidElement, useId } from "react";
3
3
  import { cx } from "../cx.js";
4
- function Field({ label, hint, error, success, required, children, className, htmlFor }) {
4
+ import { InfoTip } from "./InfoTip.js";
5
+ import { hasHelp, helpLabelFor } from "../info-tip-model.js";
6
+ function Field({ label, hint, error, success, required, help, helpLabel, children, className, htmlFor }) {
5
7
  const uid = useId();
6
8
  const msgId = `${uid}-msg`;
7
9
  const describedBy = error || success || hint ? msgId : void 0;
@@ -13,7 +15,11 @@ function Field({ label, hint, error, success, required, children, className, htm
13
15
  "aria-required": children.props["aria-required"] ?? (required || void 0)
14
16
  }) : children;
15
17
  return /* @__PURE__ */ jsxs("div", { className: cx("uix-field", className), children: [
16
- label && /* @__PURE__ */ jsx("label", { className: "uix-field__label", htmlFor: htmlFor ?? controlId, "data-required": required || void 0, children: label }),
18
+ label && hasHelp(help) ? /* @__PURE__ */ jsxs("div", { className: "uix-field__label-row", children: [
19
+ /* @__PURE__ */ jsx("label", { className: "uix-field__label", htmlFor: htmlFor ?? controlId, children: label }),
20
+ /* @__PURE__ */ jsx(InfoTip, { content: help, label: helpLabel ?? helpLabelFor(label) }),
21
+ required && /* @__PURE__ */ jsx("span", { className: "uix-field__required", "aria-hidden": "true", children: "*" })
22
+ ] }) : label && /* @__PURE__ */ jsx("label", { className: "uix-field__label", htmlFor: htmlFor ?? controlId, "data-required": required || void 0, children: label }),
17
23
  control,
18
24
  /* @__PURE__ */ jsx("div", { className: "uix-field__msg", id: msgId, children: error ? /* @__PURE__ */ jsx("span", { className: "uix-field__error", role: "alert", children: error }) : success ? /* @__PURE__ */ jsx("span", { className: "uix-field__success", role: "status", children: success }) : hint ? /* @__PURE__ */ jsx("span", { className: "uix-field__hint", children: hint }) : null })
19
25
  ] });
@@ -0,0 +1,29 @@
1
+ import * as react from 'react';
2
+ import { HTMLAttributes } from 'react';
3
+ import { Placement } from '../overlay-position.js';
4
+
5
+ interface InfoTipProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'content' | 'children'> {
6
+ /**
7
+ * Help text, rendered as plain text (never HTML). A blank line starts a new paragraph.
8
+ * Empty or whitespace-only text renders nothing, so a missing help string needs no guard.
9
+ */
10
+ content: string;
11
+ /** Accessible name of the ? button, e.g. "About: Incidents". The consumer localises it. */
12
+ label: string;
13
+ /** Preferred panel placement. Default `'bottom-start'`. */
14
+ placement?: Placement;
15
+ }
16
+ /**
17
+ * A small ? that explains a page, section or field. It is a fixed 9 px glyph, superscript at
18
+ * the top right of the text it follows (25 px hit area). The panel opens on hover (after a short delay), on
19
+ * keyboard focus, and on click or tap; a click keeps it open until a second click, Esc or a
20
+ * click outside. Esc returns focus to the button. The panel is plain text in
21
+ * the top layer (kit `Popover`, `popover="manual"`, so opening it never closes another popover)
22
+ * and is the button's `aria-describedby`, so the text is announced on focus.
23
+ *
24
+ * Do not put it inside a `<label>`, a heading or a link: `Field`, `PageHeader`, `Card` and
25
+ * `SectionHead` place it beside those through their `help` prop.
26
+ */
27
+ declare function InfoTip({ content, ...props }: InfoTipProps): react.JSX.Element | null;
28
+
29
+ export { InfoTip, type InfoTipProps };
@@ -0,0 +1,185 @@
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 { helpParagraphs } from "../info-tip-model.js";
6
+ import { Popover } from "./Popover.js";
7
+ const OPEN_DELAY = 300;
8
+ const CLOSE_DELAY = 100;
9
+ const QuestionMark = () => /* @__PURE__ */ jsx("svg", { viewBox: "262 -840 436 760", fill: "currentColor", "aria-hidden": "true", focusable: "false", children: /* @__PURE__ */ jsx("path", { d: "M424-320q0-81 14.5-116.5T500-514q41-36 62.5-62.5T584-637q0-41-27.5-68T480-732q-51 0-77.5 31T365-638l-103-44q21-64 77-111t141-47q105 0 161.5 58.5T698-641q0 50-21.5 85.5T609-475q-49 47-59.5 71.5T539-320H424Zm56 240q-33 0-56.5-23.5T400-160q0-33 23.5-56.5T480-240q33 0 56.5 23.5T560-160q0 33-23.5 56.5T480-80Z" }) });
10
+ const isShown = (el) => {
11
+ try {
12
+ return !!el?.matches(":popover-open");
13
+ } catch {
14
+ return false;
15
+ }
16
+ };
17
+ function InfoTip({ content, ...props }) {
18
+ const paragraphs = helpParagraphs(content);
19
+ if (!paragraphs.length) return null;
20
+ return /* @__PURE__ */ jsx(InfoTipControl, { paragraphs, ...props });
21
+ }
22
+ function InfoTipControl({
23
+ paragraphs,
24
+ label,
25
+ placement = "bottom-start",
26
+ className,
27
+ ...props
28
+ }) {
29
+ const wrapRef = useRef(null);
30
+ const buttonRef = useRef(null);
31
+ const timer = useRef(null);
32
+ const openRef = useRef(false);
33
+ const pinned = useRef(false);
34
+ const pressing = useRef(false);
35
+ const refocusing = useRef(false);
36
+ const [open, setOpen] = useState(false);
37
+ const panelId = `${useId()}-panel`;
38
+ const panel = () => document.getElementById(panelId);
39
+ const clearTimer = () => {
40
+ if (timer.current != null) {
41
+ clearTimeout(timer.current);
42
+ timer.current = null;
43
+ }
44
+ };
45
+ const show = () => {
46
+ clearTimer();
47
+ const p = panel();
48
+ if (p && !isShown(p)) {
49
+ try {
50
+ p.showPopover();
51
+ } catch {
52
+ }
53
+ }
54
+ openRef.current = true;
55
+ setOpen(true);
56
+ };
57
+ const hide = () => {
58
+ clearTimer();
59
+ pinned.current = false;
60
+ const p = panel();
61
+ if (p && isShown(p)) {
62
+ try {
63
+ p.hidePopover();
64
+ } catch {
65
+ }
66
+ }
67
+ openRef.current = false;
68
+ setOpen(false);
69
+ };
70
+ const later = (fn, ms) => {
71
+ clearTimer();
72
+ timer.current = window.setTimeout(fn, ms);
73
+ };
74
+ const pointerEnter = () => {
75
+ if (openRef.current) clearTimer();
76
+ else later(show, OPEN_DELAY);
77
+ };
78
+ const pointerLeave = () => {
79
+ if (!openRef.current) {
80
+ clearTimer();
81
+ return;
82
+ }
83
+ if (pinned.current || document.activeElement === buttonRef.current) return;
84
+ later(hide, CLOSE_DELAY);
85
+ };
86
+ const escape = () => {
87
+ const active = document.activeElement;
88
+ const focusInside = !active || active === document.body || !!wrapRef.current?.contains(active);
89
+ hide();
90
+ if (focusInside && active !== buttonRef.current) {
91
+ refocusing.current = true;
92
+ buttonRef.current?.focus();
93
+ refocusing.current = false;
94
+ }
95
+ };
96
+ useEffect(() => {
97
+ if (!open) return;
98
+ const onKey = (e) => {
99
+ if (e.key === "Escape") escape();
100
+ };
101
+ const onPointer = (e) => {
102
+ if (!wrapRef.current?.contains(e.target)) hide();
103
+ };
104
+ document.addEventListener("keydown", onKey);
105
+ document.addEventListener("pointerdown", onPointer, true);
106
+ return () => {
107
+ document.removeEventListener("keydown", onKey);
108
+ document.removeEventListener("pointerdown", onPointer, true);
109
+ };
110
+ }, [open]);
111
+ useEffect(() => () => {
112
+ clearTimer();
113
+ const p = panel();
114
+ if (p && isShown(p)) {
115
+ try {
116
+ p.hidePopover();
117
+ } catch {
118
+ }
119
+ }
120
+ }, []);
121
+ return /* @__PURE__ */ jsxs("span", { ref: wrapRef, className: cx("uix-info-tip", className), "data-open": open || void 0, ...props, children: [
122
+ /* @__PURE__ */ jsx(
123
+ "button",
124
+ {
125
+ ref: buttonRef,
126
+ type: "button",
127
+ className: "uix-info-tip__button",
128
+ "aria-label": label,
129
+ "aria-describedby": panelId,
130
+ onPointerEnter: pointerEnter,
131
+ onPointerLeave: pointerLeave,
132
+ onPointerDown: () => {
133
+ pressing.current = true;
134
+ },
135
+ onFocus: () => {
136
+ if (!refocusing.current) show();
137
+ },
138
+ onBlur: (e) => {
139
+ const next = e.relatedTarget;
140
+ if (!pinned.current || next && !wrapRef.current?.contains(next)) hide();
141
+ },
142
+ onClick: () => {
143
+ const viaPointer = pressing.current;
144
+ pressing.current = false;
145
+ if (openRef.current && (pinned.current || !viaPointer)) hide();
146
+ else {
147
+ show();
148
+ pinned.current = true;
149
+ }
150
+ },
151
+ onKeyDown: (e) => {
152
+ pressing.current = false;
153
+ if (e.key === "Escape" && openRef.current) {
154
+ e.preventDefault();
155
+ e.stopPropagation();
156
+ hide();
157
+ }
158
+ },
159
+ children: /* @__PURE__ */ jsx(QuestionMark, {})
160
+ }
161
+ ),
162
+ /* @__PURE__ */ jsx(
163
+ Popover,
164
+ {
165
+ id: panelId,
166
+ role: "tooltip",
167
+ popover: "manual",
168
+ anchor: buttonRef,
169
+ placement,
170
+ className: "uix-info-tip__panel",
171
+ onPointerEnter: () => {
172
+ if (openRef.current) clearTimer();
173
+ },
174
+ onPointerLeave: pointerLeave,
175
+ onPointerDown: () => {
176
+ pinned.current = true;
177
+ },
178
+ children: paragraphs.map((text, i) => /* @__PURE__ */ jsx("p", { children: text }, i))
179
+ }
180
+ )
181
+ ] });
182
+ }
183
+ export {
184
+ InfoTip
185
+ };
@@ -8,13 +8,20 @@ interface PageHeaderProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
8
8
  as?: 'h1' | 'h2';
9
9
  /** Supporting copy under the title. */
10
10
  subtitle?: ReactNode;
11
+ /**
12
+ * Plain-text help behind a ? button right after the title. Blank lines separate paragraphs;
13
+ * empty, whitespace-only or `null` renders no button.
14
+ */
15
+ help?: string | null;
16
+ /** Accessible name of the ? button. Default `"About: <title>"` when title is text; pass a localised one. */
17
+ helpLabel?: string;
11
18
  /** Right-aligned action area (buttons, menus). */
12
19
  actions?: ReactNode;
13
20
  }
14
21
  /**
15
- * Page header — eyebrow / title / subtitle + actions row, over `.uix-page-header`.
22
+ * Page header — eyebrow / title (+ optional `help` ?) / subtitle + actions row, over `.uix-page-header`.
16
23
  * The hairline + breathing room below come from the component CSS.
17
24
  */
18
- declare function PageHeader({ eyebrow, title, subtitle, actions, as: TitleTag, className, ...props }: PageHeaderProps): react.JSX.Element;
25
+ declare function PageHeader({ eyebrow, title, subtitle, help, helpLabel, actions, as: TitleTag, className, ...props }: PageHeaderProps): react.JSX.Element;
19
26
 
20
27
  export { PageHeader, type PageHeaderProps };
@@ -1,10 +1,16 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { cx } from "../cx.js";
3
- function PageHeader({ eyebrow, title, subtitle, actions, as: TitleTag = "h1", className, ...props }) {
3
+ import { InfoTip } from "./InfoTip.js";
4
+ import { hasHelp, helpLabelFor } from "../info-tip-model.js";
5
+ function PageHeader({ eyebrow, title, subtitle, help, helpLabel, actions, as: TitleTag = "h1", className, ...props }) {
6
+ const heading = /* @__PURE__ */ jsx(TitleTag, { className: "uix-page-header__title", children: title });
4
7
  return /* @__PURE__ */ jsxs("header", { className: cx("uix-page-header", className), ...props, children: [
5
8
  /* @__PURE__ */ jsxs("div", { className: "uix-page-header__titles", children: [
6
9
  eyebrow != null && /* @__PURE__ */ jsx("div", { className: "uix-page-header__eyebrow", children: eyebrow }),
7
- /* @__PURE__ */ jsx(TitleTag, { className: "uix-page-header__title", children: title }),
10
+ hasHelp(help) ? /* @__PURE__ */ jsxs("div", { className: "uix-page-header__title-row", children: [
11
+ heading,
12
+ /* @__PURE__ */ jsx(InfoTip, { content: help, label: helpLabel ?? helpLabelFor(title) })
13
+ ] }) : heading,
8
14
  subtitle != null && /* @__PURE__ */ jsx("p", { className: "uix-page-header__subtitle", children: subtitle })
9
15
  ] }),
10
16
  actions != null && /* @__PURE__ */ jsx("div", { className: "uix-page-header__actions", children: actions })
@@ -3,6 +3,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useId } from "react";
4
4
  import { cx } from "../cx.js";
5
5
  import { useDialog } from "../hooks/useDialog.js";
6
+ import { backdropDismiss } from "../hooks/backdropDismiss.js";
6
7
  const CloseIcon = () => /* @__PURE__ */ jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M4 4l8 8M12 4l-8 8" }) });
7
8
  const ChevronUp = () => /* @__PURE__ */ jsx("svg", { width: "12", height: "12", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M2 8l4-4 4 4" }) });
8
9
  const ChevronDown = () => /* @__PURE__ */ jsx("svg", { width: "12", height: "12", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M2 4l4 4 4-4" }) });
@@ -10,15 +11,7 @@ function Peek({ open, onClose, title, children, footer, onNavPrev, onNavNext, hi
10
11
  const ref = useDialog(open, onClose);
11
12
  const hasNav = onNavPrev != null || onNavNext != null;
12
13
  const titleId = useId();
13
- const onBackdropClick = (e) => {
14
- onClick?.(e);
15
- if (!onClose || e.target !== e.currentTarget) return;
16
- const r = e.currentTarget.getBoundingClientRect();
17
- if (r.width === 0) return;
18
- const outside = e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom;
19
- if (outside) onClose();
20
- };
21
- return /* @__PURE__ */ jsxs("dialog", { ref, className: cx("uix-peek", className), "aria-labelledby": title ? titleId : void 0, ...rest, onClick: onBackdropClick, children: [
14
+ return /* @__PURE__ */ jsxs("dialog", { ref, className: cx("uix-peek", className), "aria-labelledby": title ? titleId : void 0, ...rest, onClick: backdropDismiss(onClose, onClick), children: [
22
15
  /* @__PURE__ */ jsxs("div", { className: "uix-peek__header", children: [
23
16
  hasNav && /* @__PURE__ */ jsxs("div", { className: "uix-peek__nav", children: [
24
17
  /* @__PURE__ */ jsx("button", { className: "uix-peek__navbtn", onClick: () => onNavPrev?.(), "aria-disabled": onNavPrev ? void 0 : true, "aria-label": previousLabel, children: /* @__PURE__ */ jsx(ChevronUp, {}) }),
@@ -39,6 +39,8 @@ interface RichTextLabels {
39
39
  image: string;
40
40
  imageUploading: string;
41
41
  imageFailed: string;
42
+ /** Shown when an image is pasted or dropped where images are off (default for `imagesUnavailableReason`). */
43
+ imagesUnsupported: string;
42
44
  emoji: string;
43
45
  /** Accessible name of the `:shortcode` suggestion list. */
44
46
  emojiSuggestions: string;
@@ -65,11 +67,17 @@ interface RichTextEditorProps {
65
67
  features?: RichTextFeatures;
66
68
  /** Heading levels the toolbar and input rules offer. Default `[1, 2, 3]`. */
67
69
  headingLevels?: ReadonlyArray<RichTextHeadingLevel>;
68
- /** Enables the image button (preset `full`), and image paste/drop. */
70
+ /** Enables image paste/drop and the toolbar image button, in every preset. */
69
71
  onUploadImage?: (file: File) => Promise<{
70
72
  src: string;
71
73
  alt: string;
72
74
  }>;
75
+ /**
76
+ * Shown in the status line when an image is pasted or dropped while images are off (no
77
+ * `onUploadImage`), e.g. "Attach files to the incident instead.". Nothing is inserted.
78
+ * Default: the `imagesUnsupported` label.
79
+ */
80
+ imagesUnavailableReason?: string;
73
81
  /** Link policy. Default: http(s), mailto and same-app paths. */
74
82
  isSafeUrl?: (url: string) => boolean;
75
83
  /** Image policy: the URL to display, or `null` to show the image as a link chip. */
@@ -59,6 +59,7 @@ const DEFAULT_RICH_TEXT_LABELS = {
59
59
  image: "Image",
60
60
  imageUploading: "Uploading image\u2026",
61
61
  imageFailed: "The image could not be added.",
62
+ imagesUnsupported: "Images can't be added here.",
62
63
  emoji: "Emoji",
63
64
  emojiSuggestions: "Emoji suggestions",
64
65
  undo: "Undo",
@@ -70,8 +71,9 @@ const DEFAULT_RICH_TEXT_LABELS = {
70
71
  const COMMON = ["bold", "italic", "strike", "code", "bulletList", "orderedList", "taskList", "blockquote", "codeBlock", "link", "emoji", "history", "source"];
71
72
  const PRESETS = {
72
73
  full: /* @__PURE__ */ new Set([...COMMON, "heading", "horizontalRule", "table", "image"]),
73
- comment: new Set(COMMON),
74
- template: /* @__PURE__ */ new Set([...COMMON, "heading", "horizontalRule", "table"])
74
+ // Images in every preset once the consumer supplies onUploadImage (TENSOR HAR-749).
75
+ comment: /* @__PURE__ */ new Set([...COMMON, "image"]),
76
+ template: /* @__PURE__ */ new Set([...COMMON, "heading", "horizontalRule", "table", "image"])
75
77
  };
76
78
  const emojiSuggestionKey = new PluginKey("uixEmojiSuggestion");
77
79
  const SHORTCODE_RE = /:([a-zA-Z0-9_+-]+):$/;
@@ -187,7 +189,8 @@ function RichTextEditor(props) {
187
189
  minRows,
188
190
  className,
189
191
  onBlur,
190
- emojiImageBaseUrl
192
+ emojiImageBaseUrl,
193
+ imagesUnavailableReason
191
194
  } = props;
192
195
  const emojiBase = useMemo(() => normalizeEmojiImageBaseUrl(emojiImageBaseUrl), [emojiImageBaseUrl]);
193
196
  const renderEmoji = useEmojiRenderer(emojiImageBaseUrl);
@@ -199,7 +202,7 @@ function RichTextEditor(props) {
199
202
  const linkPopoverId = `${uid}-link`;
200
203
  const controls = PRESETS[features];
201
204
  const has = (control) => controls.has(control) && (control !== "emoji" || emoji) && (control !== "image" || !!onUploadImage);
202
- const latest = useRef({ onChange, onSubmitShortcut, onBlur, isSafeUrl, resolveImageSrc, onUploadImage, placeholder, imagesOn: false });
205
+ const latest = useRef({ onChange, onSubmitShortcut, onBlur, isSafeUrl, resolveImageSrc, onUploadImage, placeholder, imagesOn: false, imagesOffText: "" });
203
206
  latest.current = {
204
207
  onChange,
205
208
  onSubmitShortcut,
@@ -208,7 +211,8 @@ function RichTextEditor(props) {
208
211
  resolveImageSrc,
209
212
  onUploadImage,
210
213
  placeholder,
211
- imagesOn: controls.has("image") && !!onUploadImage
214
+ imagesOn: controls.has("image") && !!onUploadImage,
215
+ imagesOffText: imagesUnavailableReason?.trim() || labels.imagesUnsupported
212
216
  };
213
217
  const lastValue = useRef(value);
214
218
  const origin = useRef(null);
@@ -431,22 +435,31 @@ function RichTextEditor(props) {
431
435
  shouldRerenderOnTransaction: false,
432
436
  editable: !disabled && !readOnly,
433
437
  editorProps: {
438
+ // An image file pasted or dropped where images are off used to vanish silently; now the
439
+ // status line says so (TENSOR HAR-749). A paste that also carries text is a text paste:
440
+ // Excel, Word and Docs add a PNG rendering of the copied cells or text, which must not
441
+ // replace (or block) the text itself.
434
442
  handlePaste: (_view, event) => {
435
- const files = Array.from(event.clipboardData?.files ?? []).filter((f) => f.type.startsWith("image/"));
436
- if (!files.length || !latest.current.imagesOn || !editorRef.current) return false;
437
- files.forEach((file) => void uploadImage(editorRef.current, file));
443
+ const data = event.clipboardData;
444
+ const files = Array.from(data?.files ?? []).filter((f) => f.type.startsWith("image/"));
445
+ if (!files.length || !editorRef.current || data?.getData("text/plain").trim()) return false;
446
+ event.preventDefault();
447
+ if (!latest.current.imagesOn) setStatus({ kind: "notice", text: latest.current.imagesOffText });
448
+ else files.forEach((file) => void uploadImage(editorRef.current, file));
438
449
  return true;
439
450
  },
440
451
  handleDrop: (_view, event) => {
441
452
  const files = Array.from(event.dataTransfer?.files ?? []).filter((f) => f.type.startsWith("image/"));
442
- if (!files.length || !latest.current.imagesOn || !editorRef.current) return false;
453
+ if (!files.length || !editorRef.current) return false;
443
454
  event.preventDefault();
444
- files.forEach((file) => void uploadImage(editorRef.current, file));
455
+ if (!latest.current.imagesOn) setStatus({ kind: "notice", text: latest.current.imagesOffText });
456
+ else files.forEach((file) => void uploadImage(editorRef.current, file));
445
457
  return true;
446
458
  }
447
459
  },
448
460
  onCreate: ({ editor: ed }) => load(ed, lastValue.current),
449
461
  onUpdate: ({ editor: ed }) => {
462
+ setStatus((s) => s?.kind === "notice" ? null : s);
450
463
  if (!origin.current) return;
451
464
  emit(writeMarkdown(ed.getJSON(), origin.current, managerOf(ed)));
452
465
  },