@tensor_1/react 2.23.0 → 2.25.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
@@ -205,4 +205,41 @@ Radial mode still shortens long labels to fit its ring, but every node carries i
205
205
  <CopyButton value={email} label={t('copyEmail')} copiedLabel={t('copied')} failedLabel={t('copyFailed')} />
206
206
  ```
207
207
 
208
+ ### DiffViewer words and control size
209
+
210
+ - **Every word is a label.** `labels` (a `Partial<DiffViewerLabels>`, English defaults in `DEFAULT_DIFF_VIEWER_LABELS`) covers the version names, group titles, the summary, the live progress text, and the action buttons.
211
+ - **Each action is named for its entry.** The visible words come from `acceptIncoming` / `keepCurrent` / `markPending`. The accessible names come from `acceptIncomingFor` / `keepCurrentFor` / `markPendingFor`, e.g. "Accept incoming for $.limit". Each entry's three buttons sit in a group named by `resolve`. When you translate a name, keep the visible word inside it, so people using speech input can say what they see (WCAG 2.5.3).
212
+ - **`controlSize`** sets the height of the action buttons and the entry rows. `sm` (the default) is the compact 28px kit size. `md` follows `--uix-control-h`, so a theme with 60px controls gets 60px actions. `lg` is 44px.
213
+
214
+ ```tsx
215
+ <DiffViewer
216
+ base={published} current={draft} incoming={remote}
217
+ controlSize="md"
218
+ labels={{
219
+ acceptIncoming: t('acceptIncoming'), acceptIncomingFor: t('acceptIncomingFor'), // 'Prihvati dolazno za {path}'
220
+ keepCurrent: t('keepCurrent'), keepCurrentFor: t('keepCurrentFor'),
221
+ markPending: t('markPending'), markPendingFor: t('markPendingFor'),
222
+ resolve: t('resolve'), /* …the rest of DiffViewerLabels */
223
+ }}
224
+ onResolutionChange={record}
225
+ />
226
+ ```
227
+
208
228
  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)**.
229
+
230
+ ### InfoTip and `help` slots
231
+
232
+ 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`.
233
+
234
+ ```tsx
235
+ <PageHeader title={t('incidents')} help={t('incidents.help')} helpLabel={t('aboutIncidents')} />
236
+ <Card title="Open work" titleAs="h2" help={help.openWork}>…</Card>
237
+ <SectionHead title="Latest news" help={help.news} />
238
+ <Field label="Impact" required help={help.impact}><Input /></Field>
239
+ <InfoTip content={text} label="About: Impact" />
240
+ ```
241
+
242
+ - **`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.
243
+ - **`helpLabel`** is the ? button's accessible name. It defaults to `"About: <title>"` (English), so pass a translated one.
244
+ - **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.
245
+ - **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,6 +6,11 @@ import { DiffEntry, DiffResolution } from '../diff-model.js';
6
6
  * Every word the diff viewer renders (TENSOR RX-125, UIX-12). `base` / `current` /
7
7
  * `incoming` keep their meaning from before; `{path}`, `{version}`, `{resolved}` and
8
8
  * `{total}` are placeholders.
9
+ *
10
+ * `acceptIncomingFor` / `keepCurrentFor` / `markPendingFor` are the accessible names of
11
+ * the three action buttons, so a screen reader hears which entry each one resolves. Keep
12
+ * the visible word inside the translated name (WCAG 2.5.3 Label in Name): speech-input
13
+ * users say what they see.
9
14
  */
10
15
  interface DiffViewerLabels {
11
16
  base: string;
@@ -26,6 +31,9 @@ interface DiffViewerLabels {
26
31
  acceptIncoming: string;
27
32
  keepCurrent: string;
28
33
  markPending: string;
34
+ acceptIncomingFor: string;
35
+ keepCurrentFor: string;
36
+ markPendingFor: string;
29
37
  resolutionAccept: string;
30
38
  resolutionSkip: string;
31
39
  resolutionPending: string;
@@ -41,9 +49,15 @@ interface DiffViewerProps {
41
49
  resolutions?: Record<string, DiffResolution>;
42
50
  onResolutionChange?: (path: string, resolution: DiffResolution) => void;
43
51
  labels?: Partial<DiffViewerLabels>;
52
+ /**
53
+ * Height of the resolution buttons and the entry disclosure rows. `sm` (the default) is
54
+ * the compact 28px kit size; `md` follows `--uix-control-h`, so a theme that sets its
55
+ * own control height (for example 60px touch targets) gets it here too; `lg` is 44px.
56
+ */
57
+ controlSize?: 'sm' | 'md' | 'lg';
44
58
  className?: string;
45
59
  }
46
60
  /** Three-way configuration diff with per-entry keyboard-operable resolution. */
47
- declare function DiffViewer({ base, current, incoming, entries: providedEntries, resolutions: controlledResolutions, onResolutionChange, labels: labelOverrides, className, }: DiffViewerProps): react.JSX.Element;
61
+ declare function DiffViewer({ base, current, incoming, entries: providedEntries, resolutions: controlledResolutions, onResolutionChange, labels: labelOverrides, controlSize, className, }: DiffViewerProps): react.JSX.Element;
48
62
 
49
63
  export { DEFAULT_DIFF_VIEWER_LABELS, DiffViewer, type DiffViewerLabels, type DiffViewerProps };
@@ -24,6 +24,9 @@ const DEFAULT_DIFF_VIEWER_LABELS = {
24
24
  acceptIncoming: "Accept incoming",
25
25
  keepCurrent: "Keep current",
26
26
  markPending: "Mark pending",
27
+ acceptIncomingFor: "Accept incoming for {path}",
28
+ keepCurrentFor: "Keep current for {path}",
29
+ markPendingFor: "Mark pending for {path}",
27
30
  resolutionAccept: "accepted",
28
31
  resolutionSkip: "kept",
29
32
  resolutionPending: "pending",
@@ -40,10 +43,12 @@ function DiffViewer({
40
43
  resolutions: controlledResolutions,
41
44
  onResolutionChange,
42
45
  labels: labelOverrides,
46
+ controlSize = "sm",
43
47
  className
44
48
  }) {
45
49
  const labels = { ...DEFAULT_DIFF_VIEWER_LABELS, ...labelOverrides };
46
50
  const resolutionLabel = { accept: labels.resolutionAccept, skip: labels.resolutionSkip, pending: labels.resolutionPending };
51
+ const buttonSize = controlSize === "md" ? void 0 : `uix-btn--${controlSize}`;
47
52
  const id = useId();
48
53
  const entries = useMemo(() => providedEntries ?? buildThreeWayDiff(base, current, incoming), [providedEntries, base, current, incoming]);
49
54
  const [internalResolutions, setInternalResolutions] = useState({});
@@ -57,8 +62,8 @@ function DiffViewer({
57
62
  /* @__PURE__ */ jsx("h3", { children: labels.noDifferences }),
58
63
  /* @__PURE__ */ jsx("p", { children: labels.noDifferencesDetail })
59
64
  ] });
60
- return /* @__PURE__ */ jsxs("section", { className: cx("uix-diff-viewer", className), "aria-label": labels.region, children: [
61
- /* @__PURE__ */ jsxs("div", { className: "uix-diff-viewer__summary", "aria-label": labels.summary, children: [
65
+ return /* @__PURE__ */ jsxs("section", { className: cx("uix-diff-viewer", className), "aria-label": labels.region, "data-control-size": controlSize, children: [
66
+ /* @__PURE__ */ jsxs("div", { className: "uix-diff-viewer__summary", role: "group", "aria-label": labels.summary, children: [
62
67
  GROUPS.map((kind) => /* @__PURE__ */ jsxs("span", { children: [
63
68
  /* @__PURE__ */ jsx("b", { children: summary[kind] }),
64
69
  " ",
@@ -95,10 +100,10 @@ function DiffViewer({
95
100
  /* @__PURE__ */ jsx("strong", { children: labels[version] }),
96
101
  /* @__PURE__ */ jsx("pre", { children: renderValue(entry[version], labels.notPresent) })
97
102
  ] }, version)) }),
98
- /* @__PURE__ */ jsxs("div", { className: "uix-diff-viewer__actions", "aria-label": fillLabel(labels.resolve, { path: entry.path }), children: [
99
- /* @__PURE__ */ jsx("button", { type: "button", className: "uix-btn uix-btn--primary uix-btn--sm", "aria-pressed": resolution === "accept", onClick: () => resolve(entry.path, "accept"), children: labels.acceptIncoming }),
100
- /* @__PURE__ */ jsx("button", { type: "button", className: "uix-btn uix-btn--secondary uix-btn--sm", "aria-pressed": resolution === "skip", onClick: () => resolve(entry.path, "skip"), children: labels.keepCurrent }),
101
- /* @__PURE__ */ jsx("button", { type: "button", className: "uix-btn uix-btn--ghost uix-btn--sm", "aria-pressed": resolution === "pending", onClick: () => resolve(entry.path, "pending"), children: labels.markPending })
103
+ /* @__PURE__ */ jsxs("div", { className: "uix-diff-viewer__actions", role: "group", "aria-label": fillLabel(labels.resolve, { path: entry.path }), children: [
104
+ /* @__PURE__ */ jsx("button", { type: "button", className: cx("uix-btn uix-btn--primary", buttonSize), "aria-label": fillLabel(labels.acceptIncomingFor, { path: entry.path }), "aria-pressed": resolution === "accept", onClick: () => resolve(entry.path, "accept"), children: labels.acceptIncoming }),
105
+ /* @__PURE__ */ jsx("button", { type: "button", className: cx("uix-btn uix-btn--secondary", buttonSize), "aria-label": fillLabel(labels.keepCurrentFor, { path: entry.path }), "aria-pressed": resolution === "skip", onClick: () => resolve(entry.path, "skip"), children: labels.keepCurrent }),
106
+ /* @__PURE__ */ jsx("button", { type: "button", className: cx("uix-btn uix-btn--ghost", buttonSize), "aria-label": fillLabel(labels.markPendingFor, { path: entry.path }), "aria-pressed": resolution === "pending", onClick: () => resolve(entry.path, "pending"), children: labels.markPending })
102
107
  ] })
103
108
  ] }, entry.path);
104
109
  })
@@ -31,6 +31,7 @@ declare const PATHS: {
31
31
  readonly richMode: "M14 2v4a2 2 0 0 0 2 2h4M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7ZM8 13h8M8 17h5";
32
32
  readonly clock: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20ZM12 6v6l4 2";
33
33
  readonly grip: "M9 5h.01M9 12h.01M9 19h.01M15 5h.01M15 12h.01M15 19h.01";
34
+ readonly more: "M5 12h.01M12 12h.01M19 12h.01";
34
35
  };
35
36
  type EditorIconName = keyof typeof PATHS;
36
37
  declare function EditorIcon({ name, ...props }: {
@@ -28,7 +28,8 @@ const PATHS = {
28
28
  sourceMode: "M10 12.5 8 15l2 2.5M14 12.5l2 2.5-2 2.5M14 2v4a2 2 0 0 0 2 2h4M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z",
29
29
  richMode: "M14 2v4a2 2 0 0 0 2 2h4M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7ZM8 13h8M8 17h5",
30
30
  clock: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20ZM12 6v6l4 2",
31
- grip: "M9 5h.01M9 12h.01M9 19h.01M15 5h.01M15 12h.01M15 19h.01"
31
+ grip: "M9 5h.01M9 12h.01M9 19h.01M15 5h.01M15 12h.01M15 19h.01",
32
+ more: "M5 12h.01M12 12h.01M19 12h.01"
32
33
  };
33
34
  function EditorIcon({ name, ...props }) {
34
35
  return /* @__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 })
@@ -0,0 +1,87 @@
1
+ import * as react from 'react';
2
+ import { HTMLAttributes, ReactNode, Ref } from 'react';
3
+
4
+ /** One result row. */
5
+ interface SearchSuggestOption {
6
+ /** Stable id, returned by `onSelect`. */
7
+ id: string;
8
+ /** The result's name. Matched letters are emphasised. */
9
+ title: string;
10
+ /** Where the result lives, outermost first (a breadcrumb). Middle crumbs truncate first. */
11
+ meta?: readonly string[];
12
+ /** One or two lines of context, clamped at two lines. Matched letters are emphasised. */
13
+ description?: string;
14
+ /** Optional leading glyph (decorative). */
15
+ icon?: ReactNode;
16
+ }
17
+ /** A labelled action inside the list (the footer row, the heading action). */
18
+ interface SearchSuggestAction {
19
+ /** Visible text of the action. */
20
+ label: string;
21
+ /** Runs when the action is chosen. */
22
+ onSelect: () => void;
23
+ }
24
+ /** Props of {@link SearchSuggest}. */
25
+ interface SearchSuggestProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'onSelect' | 'title' | 'defaultValue'> {
26
+ /** The text in the field (controlled). */
27
+ value: string;
28
+ /** Called on every edit, and with an empty string when the field is cleared. */
29
+ onValueChange: (value: string) => void;
30
+ /** The suggestions to show, best first. The consumer filters; this component only renders. */
31
+ options: readonly SearchSuggestOption[];
32
+ /** Called when a row is chosen by click or Enter. The list closes afterwards. */
33
+ onSelect: (id: string, option: SearchSuggestOption) => void;
34
+ /** Accessible name of the field (there is no visible label; the placeholder is not a label). */
35
+ label: string;
36
+ placeholder?: string;
37
+ /** Text to emphasise in rows. Defaults to `value`. */
38
+ highlight?: string;
39
+ /** A title above the options, e.g. "Recently opened". */
40
+ heading?: string;
41
+ /** A small action next to the heading, e.g. "Clear". */
42
+ headingAction?: SearchSuggestAction;
43
+ /** A last row after the options, e.g. "Show all 23 results". Reachable by arrow keys. */
44
+ footer?: SearchSuggestAction;
45
+ /** The field is fetching suggestions: a spinner shows in the field and, with no options yet, in the list. */
46
+ loading?: boolean;
47
+ /** Announced while loading, and shown in the list when it has no rows yet. */
48
+ loadingLabel?: string;
49
+ /** Shown when the field has text, nothing is loading and there are no options. */
50
+ empty?: ReactNode;
51
+ /** Shown instead of the empty state when suggestions could not be loaded. */
52
+ error?: ReactNode;
53
+ /** A keyboard hint shown in the empty, unfocused field, e.g. "/". */
54
+ shortcutHint?: string;
55
+ /** Accessible name of the clear button (default "Clear search"). */
56
+ clearLabel?: string;
57
+ /** A polite announcement, e.g. "8 results". Rendered in a visually hidden live region. */
58
+ status?: string;
59
+ /** `lg` for a page-level search (hubs, start pages); `md` for a field in a toolbar or header. */
60
+ size?: 'md' | 'lg';
61
+ /** Controlled open state of the suggestion list (optional). */
62
+ open?: boolean;
63
+ /** Called when the list opens or closes. */
64
+ onOpenChange?: (open: boolean) => void;
65
+ /**
66
+ * `absolute` (default) places the list under the field inside its own box. `fixed` floats it on
67
+ * the viewport, so a clipping ancestor (`overflow: hidden` toolbars and headers) cannot cut it off;
68
+ * it follows the field on scroll and resize and flips above it when there is no room below.
69
+ */
70
+ strategy?: 'absolute' | 'fixed';
71
+ /** Focus the field from outside (e.g. a "/" shortcut). */
72
+ inputRef?: Ref<HTMLInputElement | null>;
73
+ /** Focus the field on mount. */
74
+ autoFocus?: boolean;
75
+ /** Form field name of the input. */
76
+ name?: string;
77
+ }
78
+ /**
79
+ * A search field with a suggestion list underneath: the "search and jump" pattern of Windows
80
+ * Settings, Android Settings and most docs sites. Each row shows a name, where it lives and a line
81
+ * of context. ARIA combobox pattern: focus stays in the field, arrow keys move the active row
82
+ * (`aria-activedescendant`), Enter opens it (the first row when none is active), Escape closes the
83
+ * list and a second Escape clears the text.
84
+ */
85
+ declare function SearchSuggest({ value, onValueChange, options, onSelect, label, placeholder, highlight, heading, headingAction, footer, loading, loadingLabel, empty, error, shortcutHint, clearLabel, status, size, open: openProp, strategy, onOpenChange, inputRef, autoFocus, name, className, onBlur, ...props }: SearchSuggestProps): react.JSX.Element;
86
+
87
+ export { SearchSuggest, type SearchSuggestAction, type SearchSuggestOption, type SearchSuggestProps };