@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 +32 -1
- package/dist/components/Card.d.ts +8 -1
- package/dist/components/Card.js +8 -2
- package/dist/components/Drawer.d.ts +7 -1
- package/dist/components/Drawer.js +3 -2
- package/dist/components/EditorialHome.d.ts +9 -2
- package/dist/components/EditorialHome.js +8 -2
- package/dist/components/Field.d.ts +9 -1
- package/dist/components/Field.js +8 -2
- package/dist/components/InfoTip.d.ts +29 -0
- package/dist/components/InfoTip.js +185 -0
- package/dist/components/PageHeader.d.ts +9 -2
- package/dist/components/PageHeader.js +8 -2
- package/dist/components/Peek.js +2 -9
- package/dist/components/RichTextEditor.d.ts +9 -1
- package/dist/components/RichTextEditor.js +23 -10
- package/dist/components/SearchSuggest.d.ts +87 -0
- package/dist/components/SearchSuggest.js +299 -0
- package/dist/hooks/backdropDismiss.d.ts +7 -0
- package/dist/hooks/backdropDismiss.js +16 -0
- package/dist/index.cjs +1800 -1212
- package/dist/index.d.cts +170 -9
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/info-tip-model.d.ts +10 -0
- package/dist/info-tip-model.js +8 -0
- package/dist/rich-text.cjs +23 -10
- package/dist/rich-text.d.cts +9 -1
- package/dist/search-suggest-model.d.ts +20 -0
- package/dist/search-suggest-model.js +74 -0
- package/package.json +1 -1
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;
|
package/dist/components/Card.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cx } from "../cx.js";
|
|
3
|
-
|
|
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
|
-
|
|
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__ */
|
|
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 };
|
package/dist/components/Field.js
CHANGED
|
@@ -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
|
-
|
|
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__ */
|
|
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
|
-
|
|
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__ */
|
|
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 })
|
package/dist/components/Peek.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
436
|
-
|
|
437
|
-
files.
|
|
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 || !
|
|
453
|
+
if (!files.length || !editorRef.current) return false;
|
|
443
454
|
event.preventDefault();
|
|
444
|
-
|
|
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
|
},
|