@tensor_1/react 2.41.0 → 2.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +24 -0
  2. package/dist/components/Button.d.ts +19 -1
  3. package/dist/components/Button.js +13 -8
  4. package/dist/components/DescriptionList.d.ts +11 -0
  5. package/dist/components/DiscardPrompt.d.ts +54 -0
  6. package/dist/components/DiscardPrompt.js +245 -0
  7. package/dist/components/Drawer.d.ts +19 -3
  8. package/dist/components/Drawer.js +60 -21
  9. package/dist/components/EditorialHome.d.ts +1 -1
  10. package/dist/components/Field.d.ts +7 -1
  11. package/dist/components/Field.js +5 -4
  12. package/dist/components/Input.d.ts +5 -0
  13. package/dist/components/Input.js +2 -1
  14. package/dist/components/Kbd.d.ts +20 -1
  15. package/dist/components/Kbd.js +30 -7
  16. package/dist/components/Menu.d.ts +11 -4
  17. package/dist/components/Menu.js +17 -3
  18. package/dist/components/MetricInput.d.ts +8 -1
  19. package/dist/components/MetricInput.js +11 -3
  20. package/dist/components/Modal.d.ts +15 -1
  21. package/dist/components/Modal.js +58 -13
  22. package/dist/components/OrgChart.d.ts +121 -0
  23. package/dist/components/OrgChart.js +683 -0
  24. package/dist/components/SearchBar.d.ts +67 -0
  25. package/dist/components/SearchBar.js +117 -0
  26. package/dist/components/Select.js +5 -4
  27. package/dist/components/ShortcutHint.d.ts +29 -0
  28. package/dist/components/ShortcutHint.js +149 -0
  29. package/dist/components/Tooltip.d.ts +10 -1
  30. package/dist/components/Tooltip.js +15 -4
  31. package/dist/emoji.cjs +108 -31
  32. package/dist/hooks/useAnchoredPosition.d.ts +13 -1
  33. package/dist/hooks/useAnchoredPosition.js +19 -11
  34. package/dist/hooks/useDialog.d.ts +6 -1
  35. package/dist/hooks/useDialog.js +10 -1
  36. package/dist/index.cjs +3911 -2567
  37. package/dist/index.d.cts +340 -39
  38. package/dist/index.d.ts +4 -1
  39. package/dist/index.js +11 -1
  40. package/dist/labels-context.d.ts +11 -0
  41. package/dist/rich-text.cjs +17 -10
  42. package/package.json +1 -1
package/README.md CHANGED
@@ -762,6 +762,30 @@ The layout grid for dashboard widgets (HAR-1555; TENSOR's role dashboards). Pure
762
762
  - Refs reach the grid and item `<div>`s, and other props pass through. For an element you render yourself (a drag-and-drop library's sortable node, a server template), use `dashboardGridClassName(columns, gap)` and `dashboardGridItemClassName(span)`.
763
763
  - Plain HTML: `.uix-dashboard-grid` with `--cols-N` / `--{sm,md,lg,xl}-cols-N` and `--gap-sm|lg`, items `.uix-dashboard-grid__item` with `--span-2` / `--full`. Every direct child of the grid is a cell.
764
764
 
765
+ ### OrgChart
766
+
767
+ Reporting lines as a top-down chart (HAR-1807; TENSOR's People directory). Managers sit above their direct reports, joined by lines; each person is a card with photo or initials, name, title, team, location and the number of direct reports, which is also the control that shows or hides them. Data fetching stays in the product: the chart asks for a person's reports through `loadChildren` the first time they are opened.
768
+
769
+ ```tsx
770
+ <OrgChart
771
+ aria-label="Organisation"
772
+ nodes={roots} // OrgChartNode[]: { id, name, title?, team?, location?, photo?, badge?, reportCount?, children? }
773
+ loadChildren={(node) => api.reports(node.id)}
774
+ expanded={expanded} onToggle={(id, open) => setExpanded(next(expanded, id, open))}
775
+ selected={personId} onSelect={openPerson}
776
+ revealId={searchHit} onReveal={() => setSearchHit(null)}
777
+ />
778
+ ```
779
+
780
+ - **Lazy reports:** a person with `reportCount > 0` and no `children` is expandable; on expand a loading row stands in for the reports, and a rejection shows an error row whose Enter, Space or click retries. Results are kept for the life of the chart. Expanding through a controlled `expanded` (a whole reporting line at once) loads each level as it appears.
781
+ - **Search:** set `expanded` to the person's reporting line and `revealId` to the person; once their card exists the chart scrolls it into view, focuses it and calls `onReveal`. Set `revealId` back to null there, so the same person can be revealed again.
782
+ - **Stacked rows:** a row of people without reports of their own is a column under their manager (`stackLeaves`, default true), so a team of eight takes one card width.
783
+ - **Frame:** zoom out / in (25–150 %, around the frame's centre), Fit (the chart's width into the frame), the level button resets to 100 %; drag the background to pan (mouse and pen; touch scrolls). Opening a manager keeps their card where it was while their reports appear. Until the reader moves the view, a chart wider than the frame stays centred on its top, and `defaultZoom={'fit'}` keeps fitting as reports load. `--org-chart-max-height` (default 70vh) caps the frame. The root is an inline-size container: give it a width (a block, or `flex: 1` in a flex row).
784
+ - **Narrow:** below 48rem of the chart's **own** width (a container query: a side panel counts as much as a phone) it is an indented vertical tree with guide lines; the toolbar steps aside.
785
+ - **Keyboard:** one tab stop (roving tabindex). In the chart ↑ is the manager, ↓ the first report (↓ on a closed manager opens them first), ← → the previous and next person on the same level across teams; in a stacked column ↑ ↓ move along it. In the list the tree keys apply (↑ ↓ visible order, → open / first report, ← close / manager). From a stacked column, ← → go to the next column. Home / End, a letter jumps to the next visible person whose name starts with it, Enter opens the person (`onSelect`), Space shows or hides their reports. A failed load is announced.
786
+ - **Words:** `labels` or `UixLabelsProvider` `orgChart` (`DEFAULT_ORG_CHART_LABELS`): the tree's default name (`chart`), loading, error, retry, `reportsOne` / `reportsMany` (`{count}`), expand / collapse, the toolbar and its buttons, `zoomLevel` / `resetZoom` (`{percent}`).
787
+ - Plain HTML: `.uix-org-chart` > `__toolbar`, `__viewport` > `__canvas` > `ul.__tree[role=tree]` > `li.__item[role=treeitem]` > `.__node` (+ `ul.__group[role=group]`, `[data-stack]` for a column). The ARIA state on the item drives the styling.
788
+
765
789
  ### Select draws its own list
766
790
 
767
791
  `Select` (HAR-1572) no longer opens the browser's dropdown. It is the WAI-ARIA select-only combobox: a `<button role="combobox">` trigger and a UIx-drawn listbox in the top layer (a bottom sheet on phones). The props did not change, so existing call sites keep working:
@@ -1,6 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { ButtonHTMLAttributes, ReactNode, AnchorHTMLAttributes } from 'react';
3
3
  import { UixRenderLink } from '../link.js';
4
+ import { KeyShortcut } from './Kbd.js';
4
5
 
5
6
  type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'link';
6
7
  /** `xs` is 24 px: the smallest hit area WCAG 2.5.8 allows, for dense tool rows (HAR-1348). */
@@ -10,6 +11,21 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
10
11
  size?: ButtonSize;
11
12
  icon?: boolean;
12
13
  loading?: boolean;
14
+ /**
15
+ * Let a long label wrap onto more lines: the size becomes a minimum height instead of a fixed
16
+ * one (HAR-1813, WCAG 1.4.10 reflow). For human-language labels on public or narrow screens;
17
+ * dense staff rows keep the one-line default. Ignored with `icon`.
18
+ */
19
+ wrap?: boolean;
20
+ /**
21
+ * The button's keyboard shortcut, in `KbdCombo` key names: `['A']`, `['Mod', 'Enter']`
22
+ * (HAR-2031). Hover or focus shows the keys in a hint above the button (an icon button's
23
+ * `aria-label` before them), and the button gets `aria-keyshortcuts` for the viewer's platform
24
+ * (⌘ / Meta on Apple, Ctrl / Control elsewhere), unless you pass `aria-keyshortcuts` yourself.
25
+ * Inside a `Tooltip` the Tooltip shows the keys in its own bubble. The kit only shows the
26
+ * shortcut: the product binds the key.
27
+ */
28
+ shortcut?: KeyShortcut;
13
29
  children?: ReactNode;
14
30
  }
15
31
  declare const Button: react.ForwardRefExoticComponent<ButtonProps & react.RefAttributes<HTMLButtonElement>>;
@@ -19,6 +35,8 @@ interface ButtonLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
19
35
  size?: ButtonSize;
20
36
  /** Icon-only link: square, and needs `aria-label`. */
21
37
  icon?: boolean;
38
+ /** Let a long label wrap (see `ButtonProps.wrap`, HAR-1813). Ignored with `icon`. */
39
+ wrap?: boolean;
22
40
  /**
23
41
  * Renders the link without `href`, with `aria-disabled="true"`, so it is announced
24
42
  * as an unavailable link and cannot be followed or focused.
@@ -32,7 +50,7 @@ interface ButtonLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
32
50
  * A link that looks like a `Button` (HAR-1348; TENSOR A4/B5, MOTUS C-3). Use it when the
33
51
  * action navigates; use `Button` when it acts in place.
34
52
  */
35
- declare function ButtonLink({ href, variant, size, icon, disabled, renderLink, className, children, onClick, ...props }: ButtonLinkProps): react.JSX.Element;
53
+ declare function ButtonLink({ href, variant, size, icon, wrap, disabled, renderLink, className, children, onClick, ...props }: ButtonLinkProps): react.JSX.Element;
36
54
  interface ButtonGroupProps {
37
55
  children?: ReactNode;
38
56
  className?: string;
@@ -1,26 +1,28 @@
1
- import { Fragment, jsx } from "react/jsx-runtime";
1
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef } from "react";
3
3
  import { cx } from "../cx.js";
4
4
  import { renderUixLink } from "../link.js";
5
- const buttonClasses = (variant, size, icon, className) => cx(
5
+ import { ShortcutHint } from "./ShortcutHint.js";
6
+ const buttonClasses = (variant, size, icon, wrap, className) => cx(
6
7
  "uix-btn",
7
8
  `uix-btn--${variant}`,
8
9
  size !== "md" && `uix-btn--${size}`,
9
10
  icon && "uix-btn--icon",
11
+ wrap && !icon && "uix-btn--wrap",
10
12
  className
11
13
  );
12
14
  let warnedIconButton = false;
13
15
  const Button = forwardRef(
14
- ({ variant = "secondary", size = "md", icon = false, loading = false, className, children, disabled, onClick, ...props }, ref) => {
16
+ ({ variant = "secondary", size = "md", icon = false, loading = false, wrap = false, shortcut, className, children, disabled, onClick, ...props }, ref) => {
15
17
  if (!warnedIconButton && icon && children == null && !props["aria-label"] && !props["aria-labelledby"] && typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
16
18
  warnedIconButton = true;
17
19
  console.warn("uix: icon-only <Button> has no accessible name \u2014 pass aria-label or aria-labelledby.");
18
20
  }
19
- return /* @__PURE__ */ jsx(
21
+ return /* @__PURE__ */ jsxs(
20
22
  "button",
21
23
  {
22
24
  ref,
23
- className: buttonClasses(variant, size, icon, className),
25
+ className: buttonClasses(variant, size, icon, wrap, className),
24
26
  disabled,
25
27
  "aria-disabled": loading || void 0,
26
28
  "aria-busy": loading || void 0,
@@ -33,14 +35,17 @@ const Button = forwardRef(
33
35
  onClick?.(e);
34
36
  } : void 0,
35
37
  ...props,
36
- children
38
+ children: [
39
+ children,
40
+ shortcut && shortcut.length > 0 && /* @__PURE__ */ jsx(ShortcutHint, { keys: shortcut, label: icon ? props["aria-label"] : void 0, ownsAria: props["aria-keyshortcuts"] === void 0 })
41
+ ]
37
42
  }
38
43
  );
39
44
  }
40
45
  );
41
46
  Button.displayName = "Button";
42
- function ButtonLink({ href, variant = "secondary", size = "md", icon = false, disabled = false, renderLink, className, children, onClick, ...props }) {
43
- const classes = buttonClasses(variant, size, icon, className);
47
+ function ButtonLink({ href, variant = "secondary", size = "md", icon = false, wrap = false, disabled = false, renderLink, className, children, onClick, ...props }) {
48
+ const classes = buttonClasses(variant, size, icon, wrap, className);
44
49
  if (disabled) {
45
50
  return /* @__PURE__ */ jsx("a", { ...props, className: classes, role: "link", "aria-disabled": "true", children });
46
51
  }
@@ -20,6 +20,17 @@ import { ReactNode, HTMLAttributes } from 'react';
20
20
  * Use `items` for data-driven lists (the common case) or the `DescriptionItem`
21
21
  * compound child for hand-authored content. Both render `<dt>`/`<dd>` pairs as
22
22
  * direct children of the grid `<dl>`, so they align into the same columns.
23
+ *
24
+ * Below 22rem of its own width (a phone, a side rail, a narrow column) each label
25
+ * sits above its value and both take the full width (HAR-2032), so a long label
26
+ * never squeezes the value. A `DetailLayout` side column always stacks.
27
+ *
28
+ * The list is an inline-size container for this, so its content no longer sizes
29
+ * it: in a box sized by its content (a flex row, a `Popover` or hover card, a
30
+ * `max-content` tooltip, an auto-layout table cell, inline-block, a float) it is
31
+ * 12rem wide, and stacked, unless you size it (`flex: 1`, a width). And an
32
+ * unnamed `@container` query or a `cqi` unit inside a value now measures the
33
+ * list: name the container you mean (`@container/card`).
23
34
  */
24
35
  interface DescriptionListItem {
25
36
  /** Stable identity for the row; falls back to the list index when omitted. */
@@ -0,0 +1,54 @@
1
+ import * as react from 'react';
2
+ import { RefObject, KeyboardEvent, ReactNode } from 'react';
3
+ import { ButtonProps } from './Button.js';
4
+ import '../link.js';
5
+ import './Kbd.js';
6
+
7
+ /** The words of the "Discard changes?" step a `dirty` Drawer or Modal asks before it closes (HAR-2029). */
8
+ interface DiscardPromptLabels {
9
+ /** The question, e.g. "Discard changes?". It names the step. */
10
+ title: string;
11
+ /** What discarding means, under the question. */
12
+ description: string;
13
+ /** The safe choice. It has focus when the step opens. */
14
+ keepEditing: string;
15
+ /** Close and throw the input away. */
16
+ discard: string;
17
+ }
18
+ type DialogCloseProps = ButtonProps;
19
+ /**
20
+ * A button that closes the surrounding `Drawer` or `Modal` the way its × does (HAR-2029): at once
21
+ * when the form is clean, through the "Discard changes?" step (placed against this button) when
22
+ * the dialog is `dirty`, and not at all while it is `pending`. Use it for the footer's Cancel.
23
+ * Its own `onClick` runs first; `event.preventDefault()` there keeps the dialog open.
24
+ */
25
+ declare const DialogClose: react.ForwardRefExoticComponent<ButtonProps & react.RefAttributes<HTMLButtonElement>>;
26
+ interface GuardedDialogOptions {
27
+ open: boolean;
28
+ onClose?: () => void;
29
+ dirty?: boolean;
30
+ pending?: boolean;
31
+ labels?: Partial<DiscardPromptLabels>;
32
+ /** The dialog's own × button: the step is placed against it unless a DialogClose asked. */
33
+ closeRef: RefObject<HTMLButtonElement | null>;
34
+ }
35
+ /**
36
+ * `useDialog` with the dismissal guard of a form dialog (HAR-2029). Every way out (Escape, the
37
+ * ×, a backdrop click, a `DialogClose`) goes through `requestClose`: a clean dialog closes at
38
+ * once, a `dirty` one opens the anchored "Discard changes?" step, a `pending` one does nothing.
39
+ *
40
+ * Escape is taken on the dialog's own keydown, after the controls inside it had their turn (a
41
+ * Select closing its list cancels the event first), and cancelling that keydown is what keeps the
42
+ * browser from closing the dialog. A close request that was not a keydown (the Android back
43
+ * gesture) arrives as `cancel`. If the browser still closes a guarded dialog by force (it may
44
+ * ignore a cancelled `cancel` without user activation), the dialog reopens at once and asks.
45
+ */
46
+ declare function useGuardedDialog({ open, onClose, dirty, pending, labels: labelsProp, closeRef }: GuardedDialogOptions): {
47
+ ref: RefObject<HTMLDialogElement>;
48
+ requestClose: (anchor?: HTMLElement | null) => void;
49
+ onKeyDown: (event: KeyboardEvent<HTMLDialogElement>) => void;
50
+ prompt: react.JSX.Element | null;
51
+ provide: (children: ReactNode) => react.JSX.Element;
52
+ };
53
+
54
+ export { DialogClose, type DialogCloseProps, type DiscardPromptLabels, type GuardedDialogOptions, useGuardedDialog };
@@ -0,0 +1,245 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { createContext, forwardRef, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
4
+ import { Button } from "./Button.js";
5
+ import { Popover } from "./Popover.js";
6
+ import { useUixLabels } from "../labels-context.js";
7
+ import { useDialog } from "../hooks/useDialog.js";
8
+ const useIsomorphicLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect;
9
+ const anyAutoPopoverOpen = () => {
10
+ try {
11
+ return !!document.querySelector('[popover]:not([popover="manual"]):popover-open');
12
+ } catch {
13
+ return false;
14
+ }
15
+ };
16
+ const DEFAULT_DISCARD_LABELS = {
17
+ title: "Discard changes?",
18
+ description: "What you entered here will be lost.",
19
+ keepEditing: "Keep editing",
20
+ discard: "Discard"
21
+ };
22
+ const DialogCloseContext = createContext(null);
23
+ const DialogClose = forwardRef(({ onClick, type = "button", ...props }, ref) => {
24
+ const dialog = useContext(DialogCloseContext);
25
+ const pending = !!dialog?.pending;
26
+ return /* @__PURE__ */ jsx(
27
+ Button,
28
+ {
29
+ ref,
30
+ type,
31
+ ...props,
32
+ ...pending ? { "aria-disabled": true } : {},
33
+ onClick: (event) => {
34
+ if (pending) {
35
+ event.preventDefault();
36
+ return;
37
+ }
38
+ onClick?.(event);
39
+ if (event.defaultPrevented) return;
40
+ dialog?.requestClose(event.currentTarget);
41
+ }
42
+ }
43
+ );
44
+ });
45
+ DialogClose.displayName = "DialogClose";
46
+ function useGuardedDialog({ open, onClose, dirty = false, pending = false, labels: labelsProp, closeRef }) {
47
+ const uixLabels = useUixLabels();
48
+ const labels = { ...DEFAULT_DISCARD_LABELS, ...uixLabels.discardPrompt, ...labelsProp };
49
+ const panelId = `${useId()}-discard`;
50
+ const [prompt, setPromptState] = useState(null);
51
+ const latest = useRef({ open, onClose, dirty, pending, prompt });
52
+ useIsomorphicLayoutEffect(() => {
53
+ latest.current = { ...latest.current, open, onClose, dirty, pending };
54
+ });
55
+ const setPrompt = useCallback((next) => {
56
+ latest.current.prompt = next;
57
+ setPromptState(next);
58
+ }, []);
59
+ const guarded = () => !!latest.current.onClose && (latest.current.dirty || latest.current.pending);
60
+ const requestClose = useCallback((anchor) => {
61
+ const { open: isOpen, onClose: close, dirty: isDirty, pending: isPending, prompt: asking } = latest.current;
62
+ if (!isOpen || !close || isPending || asking) return;
63
+ if (!isDirty) {
64
+ close();
65
+ return;
66
+ }
67
+ const active = document.activeElement;
68
+ setPrompt({ anchor: anchor ?? closeRef.current, returnTo: active instanceof HTMLElement ? active : null });
69
+ }, [closeRef, setPrompt]);
70
+ const dialogRef = useRef(null);
71
+ const backOut = useRef({ focus: null, forced: false });
72
+ const keepEditing = useCallback(() => {
73
+ const asked = latest.current.prompt;
74
+ setPrompt(null);
75
+ const target = asked?.returnTo;
76
+ const dialog = dialogRef.current;
77
+ const back = target && target !== dialog && target.isConnected && dialog?.contains(target) ? target : asked?.anchor ?? closeRef.current;
78
+ backOut.current.focus = back ?? null;
79
+ back?.focus();
80
+ }, [closeRef, setPrompt]);
81
+ const ref = useDialog(open, onClose, () => {
82
+ if (!guarded()) return false;
83
+ if (backOut.current.forced) {
84
+ backOut.current.forced = false;
85
+ const field = backOut.current.focus;
86
+ queueMicrotask(() => field?.focus());
87
+ } else if (latest.current.prompt) keepEditing();
88
+ else requestClose();
89
+ return true;
90
+ });
91
+ dialogRef.current = ref.current;
92
+ const discard = useCallback(() => {
93
+ setPrompt(null);
94
+ latest.current.onClose?.();
95
+ }, [setPrompt]);
96
+ useEffect(() => {
97
+ if (open && dirty && !pending) return;
98
+ if (!latest.current.prompt) return;
99
+ if (open && document.getElementById(panelId)?.contains(document.activeElement)) keepEditing();
100
+ else setPrompt(null);
101
+ }, [open, dirty, pending, panelId, keepEditing, setPrompt]);
102
+ useEffect(() => {
103
+ const el = ref.current;
104
+ dialogRef.current = el;
105
+ if (!el || !open) return;
106
+ const ours = (event) => event.target?.closest?.("dialog") === el;
107
+ const onCancel = (event) => {
108
+ if (!guarded()) return;
109
+ event.preventDefault();
110
+ if (latest.current.prompt) {
111
+ keepEditing();
112
+ backOut.current.forced = !event.cancelable;
113
+ } else requestClose();
114
+ };
115
+ const outside = (event) => !document.getElementById(panelId)?.contains(event.target);
116
+ let swallow = null;
117
+ const forget = () => {
118
+ swallow = null;
119
+ };
120
+ const onPointerDownCapture = (event) => {
121
+ swallow = latest.current.prompt && ours(event) && outside(event) ? event.target : null;
122
+ if (swallow) setPrompt(null);
123
+ };
124
+ const onPointerUpCapture = () => {
125
+ window.setTimeout(forget, 0);
126
+ };
127
+ const onClickCapture = (event) => {
128
+ const pressed = swallow != null && swallow === event.target;
129
+ swallow = null;
130
+ if (!pressed && !(latest.current.prompt && ours(event) && outside(event))) return;
131
+ event.preventDefault();
132
+ event.stopPropagation();
133
+ setPrompt(null);
134
+ };
135
+ el.addEventListener("cancel", onCancel);
136
+ el.addEventListener("pointerdown", onPointerDownCapture, true);
137
+ el.addEventListener("pointerup", onPointerUpCapture, true);
138
+ el.addEventListener("pointercancel", forget, true);
139
+ el.addEventListener("contextmenu", forget, true);
140
+ el.addEventListener("click", onClickCapture, true);
141
+ el.addEventListener("keydown", forget, true);
142
+ return () => {
143
+ el.removeEventListener("cancel", onCancel);
144
+ el.removeEventListener("pointerdown", onPointerDownCapture, true);
145
+ el.removeEventListener("pointerup", onPointerUpCapture, true);
146
+ el.removeEventListener("pointercancel", forget, true);
147
+ el.removeEventListener("contextmenu", forget, true);
148
+ el.removeEventListener("click", onClickCapture, true);
149
+ el.removeEventListener("keydown", forget, true);
150
+ };
151
+ }, [open, ref, requestClose, keepEditing, setPrompt, panelId]);
152
+ const onKeyDown = (event) => {
153
+ if (event.key !== "Escape" || event.defaultPrevented) return;
154
+ if (event.target.closest("dialog") !== event.currentTarget || anyAutoPopoverOpen()) return;
155
+ if (latest.current.prompt) {
156
+ event.preventDefault();
157
+ keepEditing();
158
+ return;
159
+ }
160
+ if (!guarded()) return;
161
+ event.preventDefault();
162
+ requestClose();
163
+ };
164
+ const element = dirty || prompt ? /* @__PURE__ */ jsx(
165
+ DiscardPrompt,
166
+ {
167
+ id: panelId,
168
+ prompt,
169
+ labels,
170
+ onKeepEditing: keepEditing,
171
+ onDismiss: () => setPrompt(null),
172
+ onDiscard: discard
173
+ }
174
+ ) : null;
175
+ const provide = (children) => /* @__PURE__ */ jsx(DialogCloseContext.Provider, { value: { requestClose, pending }, children });
176
+ return { ref, requestClose, onKeyDown, prompt: element, provide };
177
+ }
178
+ function DiscardPrompt({ id, prompt, labels, onKeepEditing, onDismiss, onDiscard }) {
179
+ const keepRef = useRef(null);
180
+ const open = prompt != null;
181
+ useEffect(() => {
182
+ const panel = document.getElementById(id);
183
+ if (!panel) return;
184
+ const shown = () => {
185
+ try {
186
+ return panel.matches(":popover-open");
187
+ } catch {
188
+ return false;
189
+ }
190
+ };
191
+ if (!open) {
192
+ if (shown()) {
193
+ try {
194
+ panel.hidePopover();
195
+ } catch {
196
+ }
197
+ }
198
+ return;
199
+ }
200
+ if (!shown()) {
201
+ try {
202
+ panel.showPopover();
203
+ } catch {
204
+ }
205
+ }
206
+ keepRef.current?.focus();
207
+ }, [open, id]);
208
+ return /* @__PURE__ */ jsxs(
209
+ Popover,
210
+ {
211
+ id,
212
+ popover: "manual",
213
+ anchor: prompt?.anchor ?? null,
214
+ placement: "bottom-end",
215
+ role: "alertdialog",
216
+ "aria-labelledby": `${id}-title`,
217
+ "aria-describedby": `${id}-body`,
218
+ className: "uix-popconfirm",
219
+ "data-uix-discard-prompt": "",
220
+ tabIndex: -1,
221
+ onKeyDown: (event) => {
222
+ if (event.key !== "Escape") return;
223
+ event.preventDefault();
224
+ event.stopPropagation();
225
+ onKeepEditing();
226
+ },
227
+ onBlur: (event) => {
228
+ const next = event.relatedTarget;
229
+ if (open && next && !event.currentTarget.contains(next)) onDismiss();
230
+ },
231
+ children: [
232
+ /* @__PURE__ */ jsx("div", { id: `${id}-title`, className: "uix-popconfirm__title", children: labels.title }),
233
+ /* @__PURE__ */ jsx("div", { id: `${id}-body`, className: "uix-popconfirm__body", children: labels.description }),
234
+ /* @__PURE__ */ jsxs("div", { className: "uix-popconfirm__actions", children: [
235
+ /* @__PURE__ */ jsx(Button, { ref: keepRef, type: "button", size: "sm", onClick: onKeepEditing, children: labels.keepEditing }),
236
+ /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "danger", onClick: onDiscard, children: labels.discard })
237
+ ] })
238
+ ]
239
+ }
240
+ );
241
+ }
242
+ export {
243
+ DialogClose,
244
+ useGuardedDialog
245
+ };
@@ -1,15 +1,31 @@
1
1
  import * as react from 'react';
2
2
  import { HTMLAttributes, ReactNode } from 'react';
3
+ import { DiscardPromptLabels } from './DiscardPrompt.js';
4
+ import './Button.js';
5
+ import '../link.js';
6
+ import './Kbd.js';
3
7
 
4
8
  interface DrawerProps extends Omit<HTMLAttributes<HTMLDialogElement>, 'title'> {
5
9
  /** TENSOR RX-125 (UIX-04): translatable; English default. */
6
10
  closeLabel?: string;
7
11
  open: boolean;
8
12
  onClose?: () => void;
13
+ /**
14
+ * The drawer holds input that is not saved (HAR-2029), e.g. react-hook-form's
15
+ * `formState.isDirty`. Escape, the close button, a backdrop click and a `DialogClose` then open
16
+ * an anchored "Discard changes?" step with focus on "Keep editing" instead of calling `onClose`;
17
+ * "Discard" calls it. A clean drawer closes at once. A `<form method="dialog">` submit counts
18
+ * as a way out too: save with your own handler instead.
19
+ */
20
+ dirty?: boolean;
21
+ /** A save is in flight: Escape, the close button, a backdrop click and a `DialogClose` do nothing. */
22
+ pending?: boolean;
23
+ /** The words of the discard step. Default: `UixLabelsProvider` `discardPrompt`, then English. */
24
+ discardLabels?: Partial<DiscardPromptLabels>;
9
25
  /**
10
26
  * 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`.
27
+ * (TENSOR HAR-547), or asks first when the drawer is `dirty`. `false` ignores backdrop clicks
28
+ * altogether. Default `true`.
13
29
  */
14
30
  dismissOnBackdrop?: boolean;
15
31
  /**
@@ -21,6 +37,6 @@ interface DrawerProps extends Omit<HTMLAttributes<HTMLDialogElement>, 'title'> {
21
37
  children?: ReactNode;
22
38
  footer?: ReactNode;
23
39
  }
24
- declare function Drawer({ open, onClose, dismissOnBackdrop, side, title, children, footer, closeLabel: closeLabelProp, className, onClick, ...rest }: DrawerProps): react.JSX.Element;
40
+ declare function Drawer({ open, onClose, dirty, pending, discardLabels, dismissOnBackdrop, side, title, children, footer, closeLabel: closeLabelProp, className, onClick, onKeyDown, ...rest }: DrawerProps): react.JSX.Element;
25
41
 
26
42
  export { Drawer, type DrawerProps };
@@ -1,32 +1,71 @@
1
1
  "use client";
2
- import { jsx, jsxs } from "react/jsx-runtime";
3
- import { useId } from "react";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { useId, useRef } from "react";
4
4
  import { cx } from "../cx.js";
5
5
  import { useUixLabels } from "../labels-context.js";
6
- import { useDialog } from "../hooks/useDialog.js";
7
6
  import { backdropDismiss } from "../hooks/backdropDismiss.js";
7
+ import { useGuardedDialog } from "./DiscardPrompt.js";
8
8
  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" }) });
9
- function Drawer({ open, onClose, dismissOnBackdrop = true, side = "end", title, children, footer, closeLabel: closeLabelProp, className, onClick, ...rest }) {
9
+ function Drawer({
10
+ open,
11
+ onClose,
12
+ dirty = false,
13
+ pending = false,
14
+ discardLabels,
15
+ dismissOnBackdrop = true,
16
+ side = "end",
17
+ title,
18
+ children,
19
+ footer,
20
+ closeLabel: closeLabelProp,
21
+ className,
22
+ onClick,
23
+ onKeyDown,
24
+ ...rest
25
+ }) {
10
26
  const uixLabels = useUixLabels();
11
27
  const closeLabel = closeLabelProp ?? uixLabels.drawer?.close ?? "Close drawer";
12
- const ref = useDialog(open, onClose);
28
+ const closeRef = useRef(null);
29
+ const guard = useGuardedDialog({ open, onClose, dirty, pending, labels: discardLabels, closeRef });
13
30
  const titleId = useId();
14
- return /* @__PURE__ */ jsxs("dialog", { ref, className: cx("uix-drawer", side !== "end" && `uix-drawer--${side}`, className), "aria-labelledby": title ? titleId : void 0, ...rest, onClick: backdropDismiss(onClose, onClick, dismissOnBackdrop), children: [
15
- (title != null || onClose) && /* @__PURE__ */ jsxs("div", { className: "uix-drawer__header", children: [
16
- title && /* @__PURE__ */ jsx("h2", { id: titleId, style: { fontWeight: 600, fontSize: "var(--uix-text-h3)", fontFamily: "inherit", letterSpacing: "inherit", lineHeight: "inherit" }, children: title }),
17
- onClose && /* @__PURE__ */ jsx(
18
- "button",
19
- {
20
- style: { marginLeft: "auto", border: 0, background: "transparent", color: "var(--uix-text-muted)", cursor: "pointer", width: 30, height: 30, display: "grid", placeItems: "center", borderRadius: "var(--uix-radius-sm)" },
21
- onClick: onClose,
22
- "aria-label": closeLabel,
23
- children: /* @__PURE__ */ jsx(CloseIcon, {})
24
- }
25
- )
26
- ] }),
27
- children != null && /* @__PURE__ */ jsx("div", { className: "uix-drawer__body", children }),
28
- footer != null && /* @__PURE__ */ jsx("div", { className: "uix-drawer__footer", children: footer })
29
- ] });
31
+ return /* @__PURE__ */ jsxs(
32
+ "dialog",
33
+ {
34
+ ref: guard.ref,
35
+ className: cx("uix-drawer", side !== "end" && `uix-drawer--${side}`, className),
36
+ "aria-labelledby": title ? titleId : void 0,
37
+ ...rest,
38
+ onClick: backdropDismiss(onClose && (() => guard.requestClose()), onClick, dismissOnBackdrop),
39
+ onKeyDown: (event) => {
40
+ onKeyDown?.(event);
41
+ guard.onKeyDown(event);
42
+ },
43
+ children: [
44
+ guard.provide(
45
+ /* @__PURE__ */ jsxs(Fragment, { children: [
46
+ (title != null || onClose) && /* @__PURE__ */ jsxs("div", { className: "uix-drawer__header", children: [
47
+ title && /* @__PURE__ */ jsx("h2", { id: titleId, style: { fontWeight: 600, fontSize: "var(--uix-text-h3)", fontFamily: "inherit", letterSpacing: "inherit", lineHeight: "inherit" }, children: title }),
48
+ onClose && /* @__PURE__ */ jsx(
49
+ "button",
50
+ {
51
+ ref: closeRef,
52
+ type: "button",
53
+ className: "uix-drawer__close",
54
+ onClick: () => guard.requestClose(),
55
+ "aria-label": closeLabel,
56
+ "aria-disabled": pending || void 0,
57
+ children: /* @__PURE__ */ jsx(CloseIcon, {})
58
+ }
59
+ )
60
+ ] }),
61
+ children != null && /* @__PURE__ */ jsx("div", { className: "uix-drawer__body", children }),
62
+ footer != null && /* @__PURE__ */ jsx("div", { className: "uix-drawer__footer", children: footer })
63
+ ] })
64
+ ),
65
+ guard.prompt
66
+ ]
67
+ }
68
+ );
30
69
  }
31
70
  export {
32
71
  Drawer
@@ -9,7 +9,7 @@ interface PageIntroProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
9
9
  lede?: ReactNode;
10
10
  /** Label shown above the search well. */
11
11
  searchLabel?: ReactNode;
12
- /** The search well itself (form + `.uix-searchbar` markup, or `SearchBar`-style composition). */
12
+ /** The search well itself: a `SearchBar size="lg"` (HAR-2030), or `.uix-searchbar` markup. */
13
13
  search?: ReactNode;
14
14
  /** Shortcut buttons; rendered inside the `.uix-shortcut-grid` full-width row. */
15
15
  shortcuts?: ReactNode;
@@ -5,6 +5,12 @@ interface FieldProps {
5
5
  label?: string;
6
6
  hint?: string;
7
7
  error?: string;
8
+ /**
9
+ * A value to check, not an error (HAR-1814): shown under the control in the warning colour,
10
+ * announced politely, and marks the control `data-warning`. `error` wins over it; it wins
11
+ * over `success` and `hint`.
12
+ */
13
+ warning?: string;
8
14
  success?: string;
9
15
  required?: boolean;
10
16
  /**
@@ -19,6 +25,6 @@ interface FieldProps {
19
25
  className?: string;
20
26
  htmlFor?: string;
21
27
  }
22
- declare function Field({ label, hint, error, success, required, help, helpLabel, children, className, htmlFor }: FieldProps): react.JSX.Element;
28
+ declare function Field({ label, hint, error, warning, success, required, help, helpLabel, children, className, htmlFor }: FieldProps): react.JSX.Element;
23
29
 
24
30
  export { Field, type FieldProps };
@@ -3,16 +3,17 @@ import { cloneElement, isValidElement, useId } from "react";
3
3
  import { cx } from "../cx.js";
4
4
  import { InfoTip } from "./InfoTip.js";
5
5
  import { hasHelp, helpLabelFor } from "../info-tip-model.js";
6
- function Field({ label, hint, error, success, required, help, helpLabel, children, className, htmlFor }) {
6
+ function Field({ label, hint, error, warning, success, required, help, helpLabel, children, className, htmlFor }) {
7
7
  const uid = useId();
8
8
  const msgId = `${uid}-msg`;
9
- const describedBy = error || success || hint ? msgId : void 0;
9
+ const describedBy = error || warning || success || hint ? msgId : void 0;
10
10
  const controlId = isValidElement(children) ? children.props.id ?? `${uid}-ctl` : void 0;
11
11
  const control = isValidElement(children) ? cloneElement(children, {
12
12
  id: controlId,
13
13
  "aria-describedby": [children.props["aria-describedby"], describedBy].filter(Boolean).join(" ") || void 0,
14
14
  "aria-invalid": error ? true : children.props["aria-invalid"],
15
- "aria-required": children.props["aria-required"] ?? (required || void 0)
15
+ "aria-required": children.props["aria-required"] ?? (required || void 0),
16
+ "data-warning": children.props["data-warning"] ?? (warning && !error || void 0)
16
17
  }) : children;
17
18
  return /* @__PURE__ */ jsxs("div", { className: cx("uix-field", className), children: [
18
19
  label && hasHelp(help) ? /* @__PURE__ */ jsxs("div", { className: "uix-field__label-row", children: [
@@ -21,7 +22,7 @@ function Field({ label, hint, error, success, required, help, helpLabel, childre
21
22
  required && /* @__PURE__ */ jsx("span", { className: "uix-field__required", "aria-hidden": "true", children: "*" })
22
23
  ] }) : label && /* @__PURE__ */ jsx("label", { className: "uix-field__label", htmlFor: htmlFor ?? controlId, "data-required": required || void 0, children: label }),
23
24
  control,
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 })
25
+ /* @__PURE__ */ jsx("div", { className: "uix-field__msg", id: msgId, children: error ? /* @__PURE__ */ jsx("span", { className: "uix-field__error", role: "alert", children: error }) : warning ? /* @__PURE__ */ jsx("span", { className: "uix-field__warning", role: "status", children: warning }) : success ? /* @__PURE__ */ jsx("span", { className: "uix-field__success", role: "status", children: success }) : hint ? /* @__PURE__ */ jsx("span", { className: "uix-field__hint", children: hint }) : null })
25
26
  ] });
26
27
  }
27
28
  export {