@tensor_1/react 2.45.1 → 2.47.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 (40) hide show
  1. package/README.md +57 -0
  2. package/dist/components/BusyBar.d.ts +13 -0
  3. package/dist/components/BusyBar.js +10 -0
  4. package/dist/components/BusyRegion.d.ts +18 -0
  5. package/dist/components/BusyRegion.js +12 -0
  6. package/dist/components/Button.d.ts +21 -0
  7. package/dist/components/Button.js +5 -3
  8. package/dist/components/ButtonFeedback.d.ts +30 -0
  9. package/dist/components/ButtonFeedback.js +78 -0
  10. package/dist/components/CommandPalette.d.ts +4 -0
  11. package/dist/components/CommandPalette.js +10 -2
  12. package/dist/components/DelayedRender.d.ts +15 -0
  13. package/dist/components/DelayedRender.js +10 -0
  14. package/dist/components/DiscardPrompt.d.ts +1 -0
  15. package/dist/components/Drawer.d.ts +1 -0
  16. package/dist/components/Meter.js +1 -1
  17. package/dist/components/Modal.d.ts +7 -1
  18. package/dist/components/Modal.js +2 -0
  19. package/dist/components/Progress.js +1 -1
  20. package/dist/components/SaveStatus.d.ts +7 -1
  21. package/dist/components/SaveStatus.js +13 -4
  22. package/dist/components/SearchBar.d.ts +1 -0
  23. package/dist/components/Spinner.d.ts +7 -1
  24. package/dist/components/Spinner.js +7 -1
  25. package/dist/components/States.d.ts +7 -1
  26. package/dist/components/States.js +7 -1
  27. package/dist/components/Table.d.ts +7 -0
  28. package/dist/components/Table.js +4 -2
  29. package/dist/hooks/useDelayedPending.d.ts +22 -0
  30. package/dist/hooks/useDelayedPending.js +35 -0
  31. package/dist/hooks/useReducedMotion.d.ts +6 -0
  32. package/dist/hooks/useReducedMotion.js +20 -0
  33. package/dist/index.cjs +2625 -2429
  34. package/dist/index.d.cts +116 -5
  35. package/dist/index.d.ts +5 -0
  36. package/dist/index.js +13 -0
  37. package/dist/labels-context.d.ts +3 -0
  38. package/dist/motion-timing.d.ts +8 -0
  39. package/dist/motion-timing.js +8 -0
  40. package/package.json +1 -1
package/README.md CHANGED
@@ -217,6 +217,45 @@ Radial mode still shortens long labels to fit its ring, but every node carries i
217
217
  <TabPanel value="history" keepMounted>{history}</TabPanel>
218
218
  ```
219
219
 
220
+ ### State feedback: pending, result, refetch (motion standard §2.3, §2.5)
221
+
222
+ The kit follows one latency ladder: nothing new for the first 300 ms of an action, then a pending
223
+ indicator that stays at least 500 ms; an in-place result for 1.5 s. The waits are exported as
224
+ `PENDING_DELAY_MS`, `PENDING_MIN_VISIBLE_MS` and `RESULT_HOLD_MS`.
225
+
226
+ - **`Button loading`** blocks presses at once (`aria-disabled`, `aria-busy`) but looks the same for
227
+ 300 ms; then the spinner shows, in the button's full colour, for at least 500 ms. A fast action
228
+ never flashes one. `loadingDelay={0}` shows it at once. Pass `loading`, never only
229
+ `disabled={isPending}`: a greyed-out button says nothing about work.
230
+ - **`Button result`** (`'success' | 'error' | null`): when it changes, or an action ends with one,
231
+ the label crossfades to a check and `resultLabels.success` ("Done") or an alert mark and
232
+ `resultLabels.error` ("Failed") for 1.5 s, announced politely, then returns. Never on the first
233
+ render. The button is always as wide as its widest wording, so nothing beside it moves. Keep the
234
+ prop defined (`null` while there is no result). Say what failed and how to recover somewhere that
235
+ stays (a field error, a toast). Words also come from `UixLabelsProvider` `buttonResult`.
236
+ - **`SaveStatus`** waits 300 ms before "Saving…" (`delay`); a fast save goes straight to "Saved".
237
+ - **`Spinner delay` / `LoadingState delay`** (`true` = 300 ms) render nothing until the delay has
238
+ passed. To also hold a loader 500 ms once shown, mount it from `useDelayedPending(pending)`.
239
+ - **`BusyRegion busy`** and **`TableWrap refreshing`** keep the rows on a refetch (filter, sort,
240
+ page, saved view): the content dims under an indeterminate bar and the region is `aria-busy`.
241
+ Skeletons are for a first load only; keep the previous data while fetching
242
+ (TanStack Query `placeholderData: keepPreviousData`).
243
+
244
+ ```tsx
245
+ const assign = trpc.incident.assign.useMutation();
246
+ <Button loading={assign.isPending} result={assign.isSuccess ? 'success' : assign.isError ? 'error' : null}
247
+ resultLabels={{ success: t('assigned'), error: t('notAssigned') }} onClick={() => assign.mutate(input)}>
248
+ {t('assignToMe')}
249
+ </Button>
250
+
251
+ const list = trpc.incident.list.useQuery(filters, { placeholderData: keepPreviousData });
252
+ <TableWrap refreshing={list.isFetching && !list.isPending}>…</TableWrap>
253
+ <BusyRegion busy={feed.isFetching && !feed.isPending}>{items}</BusyRegion>
254
+
255
+ const showSpinner = useDelayedPending(upload.isPending);
256
+ {showSpinner && <Spinner size="sm" label={t('uploading')} />}
257
+ ```
258
+
220
259
  ### Scrolling tabs, toned stat tiles and copying
221
260
 
222
261
  - **`Tabs overflow="scroll"`** keeps a long tab row on one line that scrolls sideways. The selected tab is always scrolled into view, and edge buttons appear only on the side that has hidden tabs. The buttons are pointer-only: keyboard users move with Arrow, Home and End. Combine it with `TabPanel keepMounted` on a record page with many sections.
@@ -256,6 +295,24 @@ Radial mode still shortens long labels to fit its ring, but every node carries i
256
295
 
257
296
  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)**.
258
297
 
298
+ ### Motion: reduced motion, instant dialogs
299
+
300
+ The motion contract lives on the docs Foundations → Motion page and is checked by `check-motion.mjs`. Under
301
+ `prefers-reduced-motion: reduce` the stylesheets drop movement and keep fades, and the loading indicators pulse
302
+ instead of freezing. Motion that your JavaScript drives (a swipe, a scripted scroll, a pan or zoom) reads the same
303
+ preference with `useReducedMotion()` (or `prefersReducedMotion()` in an event handler) and lands on the end state.
304
+
305
+ `Modal instant` opens and closes with no animation, for a keyboard-initiated or many-times-a-day dialog.
306
+ `CommandPalette` marks the dialog or popover it sits in itself, so a palette in a plain `Modal` is instant without
307
+ `data-uix-cmdk-dialog`. `Meter` and `Progress` move on `transform` (they set `--val`, 0–1).
308
+
309
+ ```tsx
310
+ const reduce = useReducedMotion();
311
+ graph.fitView({ animate: !reduce });
312
+
313
+ <Modal open={open} onClose={close} aria-label="Command palette"><CommandPalette>…</CommandPalette></Modal>
314
+ ```
315
+
259
316
  ### Drawer closes on a backdrop click
260
317
 
261
318
  `Drawer` closes on a click on the dimmed backdrop, as well as on Escape and its close button, and calls the same
@@ -0,0 +1,13 @@
1
+ import * as react from 'react';
2
+
3
+ /**
4
+ * The top indeterminate bar of a `BusyRegion` or a refreshing `TableWrap` (HAR-2582). It
5
+ * appears after 300 ms of `busy` and stays at least 500 ms; the parent dims its content
6
+ * while the bar shows (`:has(> .uix-busy-bar[data-shown])`), so the parent itself stays a
7
+ * server component. Decorative: the parent carries `aria-busy`.
8
+ */
9
+ declare function BusyBar({ busy }: {
10
+ busy: boolean;
11
+ }): react.JSX.Element;
12
+
13
+ export { BusyBar };
@@ -0,0 +1,10 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { useDelayedPending } from "../hooks/useDelayedPending.js";
4
+ function BusyBar({ busy }) {
5
+ const shown = useDelayedPending(busy);
6
+ return /* @__PURE__ */ jsx("span", { className: "uix-busy-bar", "aria-hidden": "true", "data-shown": shown || void 0 });
7
+ }
8
+ export {
9
+ BusyBar
10
+ };
@@ -0,0 +1,18 @@
1
+ import * as react from 'react';
2
+ import { HTMLAttributes, ReactNode } from 'react';
3
+
4
+ interface BusyRegionProps extends HTMLAttributes<HTMLDivElement> {
5
+ /** The content is being fetched again (a filter, sort, page or saved-view change). */
6
+ busy: boolean;
7
+ children?: ReactNode;
8
+ }
9
+ /**
10
+ * Keeps what is on screen while it is fetched again (HAR-2582, motion standard §2.5): the
11
+ * children stay, dim, and an indeterminate bar runs along the top; `aria-busy` is set at
12
+ * once. The bar and the dim wait 300 ms, so a quick refetch shows nothing, and then stay at
13
+ * least 500 ms. A skeleton is for a first load only: swapping rows for skeletons on every
14
+ * refetch hides what changed. For a table, `TableWrap refreshing` does the same.
15
+ */
16
+ declare function BusyRegion({ busy, className, children, ...props }: BusyRegionProps): react.JSX.Element;
17
+
18
+ export { BusyRegion, type BusyRegionProps };
@@ -0,0 +1,12 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { cx } from "../cx.js";
3
+ import { BusyBar } from "./BusyBar.js";
4
+ function BusyRegion({ busy, className, children, ...props }) {
5
+ return /* @__PURE__ */ jsxs("div", { className: cx("uix-busy-region", className), "aria-busy": busy || void 0, ...props, children: [
6
+ /* @__PURE__ */ jsx(BusyBar, { busy }),
7
+ /* @__PURE__ */ jsx("div", { className: "uix-busy-region__content", children })
8
+ ] });
9
+ }
10
+ export {
11
+ BusyRegion
12
+ };
@@ -2,6 +2,7 @@ import * as react from 'react';
2
2
  import { ButtonHTMLAttributes, ReactNode, AnchorHTMLAttributes } from 'react';
3
3
  import { UixRenderLink } from '../link.js';
4
4
  import { KeyShortcut } from './Kbd.js';
5
+ import { ButtonResult, ButtonResultLabels } from './ButtonFeedback.js';
5
6
 
6
7
  type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'link';
7
8
  /** `xs` is 24 px: the smallest hit area WCAG 2.5.8 allows, for dense tool rows (HAR-1348). */
@@ -10,7 +11,27 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
10
11
  variant?: ButtonVariant;
11
12
  size?: ButtonSize;
12
13
  icon?: boolean;
14
+ /**
15
+ * The button's action is running. The button stops taking presses at once and is announced
16
+ * busy; the spinner appears only after `loadingDelay` and then stays at least 500 ms, so a
17
+ * fast action never flashes one (HAR-2580, motion standard §2.3). The button keeps its full
18
+ * colour while it works: a spinner, not a greyed-out control.
19
+ */
13
20
  loading?: boolean;
21
+ /** Milliseconds before the spinner shows (default 300). `0` shows it at once. */
22
+ loadingDelay?: number;
23
+ /**
24
+ * How the button's last action ended (HAR-2581). When it changes to `success` or `error`,
25
+ * or an action ends with one, the label crossfades to a check and `resultLabels.success`
26
+ * ("Done") or an alert mark and `resultLabels.error` ("Failed") for 1.5 s, announced
27
+ * politely, then returns. Never on the first render. The button is always as wide as its
28
+ * widest wording, so nothing beside it moves. Pass `null` while there is no result (keep
29
+ * the prop defined); say what failed and how to recover somewhere that stays, such as a
30
+ * field error or a toast. e.g. `result={m.isSuccess ? 'success' : m.isError ? 'error' : null}`
31
+ */
32
+ result?: ButtonResult | null;
33
+ /** The result words, e.g. `{ success: 'Assigned', error: 'Not assigned' }`; or `UixLabelsProvider` `buttonResult`. */
34
+ resultLabels?: Partial<ButtonResultLabels>;
14
35
  /**
15
36
  * Let a long label wrap onto more lines: the size becomes a minimum height instead of a fixed
16
37
  * one (HAR-1813, WCAG 1.4.10 reflow). For human-language labels on public or narrow screens;
@@ -3,6 +3,8 @@ import { forwardRef } from "react";
3
3
  import { cx } from "../cx.js";
4
4
  import { renderUixLink } from "../link.js";
5
5
  import { ShortcutHint } from "./ShortcutHint.js";
6
+ import { ButtonFeedback } from "./ButtonFeedback.js";
7
+ import { PENDING_DELAY_MS } from "../motion-timing.js";
6
8
  const buttonClasses = (variant, size, icon, wrap, className) => cx(
7
9
  "uix-btn",
8
10
  `uix-btn--${variant}`,
@@ -13,7 +15,7 @@ const buttonClasses = (variant, size, icon, wrap, className) => cx(
13
15
  );
14
16
  let warnedIconButton = false;
15
17
  const Button = forwardRef(
16
- ({ variant = "secondary", size = "md", icon = false, loading = false, wrap = false, shortcut, className, children, disabled, onClick, ...props }, ref) => {
18
+ ({ variant = "secondary", size = "md", icon = false, loading, loadingDelay = PENDING_DELAY_MS, result, resultLabels, wrap = false, shortcut, className, children, disabled, onClick, ...props }, ref) => {
17
19
  if (!warnedIconButton && icon && children == null && !props["aria-label"] && !props["aria-labelledby"] && typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
18
20
  warnedIconButton = true;
19
21
  console.warn("uix: icon-only <Button> has no accessible name \u2014 pass aria-label or aria-labelledby.");
@@ -26,7 +28,7 @@ const Button = forwardRef(
26
28
  disabled,
27
29
  "aria-disabled": loading || void 0,
28
30
  "aria-busy": loading || void 0,
29
- "data-loading": loading || void 0,
31
+ "data-pending": loading || void 0,
30
32
  onClick: loading || onClick ? (e) => {
31
33
  if (loading) {
32
34
  e.preventDefault();
@@ -36,7 +38,7 @@ const Button = forwardRef(
36
38
  } : void 0,
37
39
  ...props,
38
40
  children: [
39
- children,
41
+ loading !== void 0 || result !== void 0 ? /* @__PURE__ */ jsx(ButtonFeedback, { loading: !!loading, loadingDelay, result, resultLabels, children }) : children,
40
42
  shortcut && shortcut.length > 0 && /* @__PURE__ */ jsx(ShortcutHint, { keys: shortcut, label: icon ? props["aria-label"] : void 0, ownsAria: props["aria-keyshortcuts"] === void 0 })
41
43
  ]
42
44
  }
@@ -0,0 +1,30 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+
4
+ type ButtonResult = 'success' | 'error';
5
+ /** The words a `Button` shows and announces for a moment after its action ends. */
6
+ interface ButtonResultLabels {
7
+ success: string;
8
+ error: string;
9
+ }
10
+ declare const DEFAULT_BUTTON_RESULT_LABELS: ButtonResultLabels;
11
+ interface ButtonFeedbackProps {
12
+ loading: boolean;
13
+ loadingDelay: number;
14
+ result?: ButtonResult | null;
15
+ resultLabels?: Partial<ButtonResultLabels>;
16
+ children?: ReactNode;
17
+ }
18
+ /**
19
+ * The client half of `Button`'s `loading` and `result` (HAR-2580, HAR-2581). `Button` itself
20
+ * stays server-renderable; this child keeps the timers. It marks the button busy through a
21
+ * hidden marker (`[data-uix-btn-busy]`, read by `.uix-btn:has(…)`), so the button element is
22
+ * never re-created and keeps focus. With a `result` it also stacks the label and the result
23
+ * words in one grid cell, so the button is always as wide as the widest of them, and
24
+ * announces the result through a polite status region portalled to the body (inside the
25
+ * button it would become part of the button's name), or to the nearest dialog, since a modal
26
+ * dialog makes the body inert. An icon-only button shows the mark alone; the words are spoken.
27
+ */
28
+ declare function ButtonFeedback({ loading, loadingDelay, result, resultLabels, children }: ButtonFeedbackProps): react.JSX.Element;
29
+
30
+ export { ButtonFeedback, type ButtonFeedbackProps, type ButtonResult, type ButtonResultLabels, DEFAULT_BUTTON_RESULT_LABELS };
@@ -0,0 +1,78 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { useDelayedPending } from "../hooks/useDelayedPending.js";
6
+ import { useUixLabels } from "../labels-context.js";
7
+ import { RESULT_HOLD_MS } from "../motion-timing.js";
8
+ const DEFAULT_BUTTON_RESULT_LABELS = {
9
+ success: "Done",
10
+ error: "Failed"
11
+ };
12
+ const SuccessGlyph = () => /* @__PURE__ */ jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M20 6 9 17l-5-5" }) });
13
+ const ErrorGlyph = () => /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
14
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "10" }),
15
+ /* @__PURE__ */ jsx("line", { x1: "12", x2: "12", y1: "8", y2: "12" }),
16
+ /* @__PURE__ */ jsx("line", { x1: "12", x2: "12.01", y1: "16", y2: "16" })
17
+ ] });
18
+ function ButtonFeedback({ loading, loadingDelay, result, resultLabels, children }) {
19
+ const busy = useDelayedPending(loading, { delay: loadingDelay });
20
+ const fromProvider = useUixLabels().buttonResult;
21
+ const text = { ...DEFAULT_BUTTON_RESULT_LABELS, ...fromProvider, ...resultLabels };
22
+ const [queued, setQueued] = useState(null);
23
+ const [shown, setShown] = useState(null);
24
+ const previous = useRef({ result: result ?? null, loading });
25
+ useEffect(() => {
26
+ const before = previous.current;
27
+ previous.current = { result: result ?? null, loading };
28
+ if (loading) {
29
+ setQueued(null);
30
+ setShown(null);
31
+ return;
32
+ }
33
+ if (result && (result !== before.result || before.loading)) setQueued(result);
34
+ }, [result, loading]);
35
+ useEffect(() => {
36
+ if (!queued || busy || loading) return;
37
+ setShown((current) => ({ kind: queued, n: (current?.n ?? 0) + 1 }));
38
+ setQueued(null);
39
+ }, [queued, busy, loading]);
40
+ useEffect(() => {
41
+ if (!shown) return void 0;
42
+ const timer = setTimeout(() => setShown(null), RESULT_HOLD_MS);
43
+ return () => clearTimeout(timer);
44
+ }, [shown]);
45
+ const markerRef = useRef(null);
46
+ const [portalTarget, setPortalTarget] = useState(null);
47
+ const withResult = result !== void 0;
48
+ useEffect(() => {
49
+ if (withResult) setPortalTarget(markerRef.current?.closest("dialog") ?? document.body);
50
+ }, [withResult]);
51
+ const marker = /* @__PURE__ */ jsx("span", { ref: markerRef, hidden: true, "data-uix-btn-busy": busy || void 0 });
52
+ if (!withResult) return /* @__PURE__ */ jsxs(Fragment, { children: [
53
+ marker,
54
+ children
55
+ ] });
56
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
57
+ marker,
58
+ /* @__PURE__ */ jsxs("span", { className: "uix-btn__face", "data-result": shown?.kind, children: [
59
+ /* @__PURE__ */ jsx("span", { className: "uix-btn__label", children }),
60
+ /* @__PURE__ */ jsxs("span", { className: "uix-btn__result", "data-kind": "success", "aria-hidden": "true", children: [
61
+ /* @__PURE__ */ jsx(SuccessGlyph, {}),
62
+ /* @__PURE__ */ jsx("span", { className: "uix-btn__result-text", children: text.success })
63
+ ] }),
64
+ /* @__PURE__ */ jsxs("span", { className: "uix-btn__result", "data-kind": "error", "aria-hidden": "true", children: [
65
+ /* @__PURE__ */ jsx(ErrorGlyph, {}),
66
+ /* @__PURE__ */ jsx("span", { className: "uix-btn__result-text", children: text.error })
67
+ ] })
68
+ ] }),
69
+ portalTarget && createPortal(
70
+ /* @__PURE__ */ jsx("span", { role: "status", className: "uix-visually-hidden", children: shown ? text[shown.kind] : "" }),
71
+ portalTarget
72
+ )
73
+ ] });
74
+ }
75
+ export {
76
+ ButtonFeedback,
77
+ DEFAULT_BUTTON_RESULT_LABELS
78
+ };
@@ -18,6 +18,10 @@ interface CommandPaletteProps extends HTMLAttributes<HTMLDivElement> {
18
18
  * Modal/Popover and wire search + keyboard selection in the consumer. The input is
19
19
  * a combobox over the listbox of `CommandItem`s; spreads override every default, so
20
20
  * consumers already passing their own roles/labels keep working.
21
+ *
22
+ * ⌘K is keyboard-initiated and used many times a day, so the palette marks the dialog or
23
+ * popover it sits in `[data-uix-instant]`: it opens and closes with no animation, without the
24
+ * consumer knowing the attribute (or the older `data-uix-cmdk-dialog`).
21
25
  */
22
26
  declare function CommandPalette({ inputProps, children, inputLabel: inputLabelProp, resultsLabel, className, ...props }: CommandPaletteProps): react.JSX.Element;
23
27
  interface CommandGroupProps {
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
3
+ import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
4
4
  import { cx } from "../cx.js";
5
5
  import { fillLabel } from "../fill-label.js";
6
6
  import { useUixLabels } from "../labels-context.js";
7
7
  const CmdkContext = createContext(null);
8
+ const useIsomorphicLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect;
8
9
  function CommandPalette({ inputProps, children, inputLabel: inputLabelProp, resultsLabel, className, ...props }) {
9
10
  const uixLabels = useUixLabels();
10
11
  const inputLabel = inputLabelProp ?? uixLabels.commandPalette?.input ?? "Command palette";
@@ -14,6 +15,13 @@ function CommandPalette({ inputProps, children, inputLabel: inputLabelProp, resu
14
15
  const resultsTextRef = useRef(resultsText);
15
16
  resultsTextRef.current = resultsText;
16
17
  const listId = useId();
18
+ const rootRef = useRef(null);
19
+ useIsomorphicLayoutEffect(() => {
20
+ const overlay = rootRef.current?.closest("dialog, [popover]");
21
+ if (!overlay || overlay.hasAttribute("data-uix-instant")) return;
22
+ overlay.setAttribute("data-uix-instant", "");
23
+ return () => overlay.removeAttribute("data-uix-instant");
24
+ }, []);
17
25
  const [activeId, setActiveId] = useState(null);
18
26
  const [count, setCount] = useState(0);
19
27
  const [announced, setAnnounced] = useState("");
@@ -29,7 +37,7 @@ function CommandPalette({ inputProps, children, inputLabel: inputLabelProp, resu
29
37
  return () => clearTimeout(t);
30
38
  }, [count]);
31
39
  const ctx = useMemo(() => ({ listId, setActive, registerItem }), [listId, setActive, registerItem]);
32
- return /* @__PURE__ */ jsx(CmdkContext.Provider, { value: ctx, children: /* @__PURE__ */ jsxs("div", { className: cx("uix-cmdk", className), ...props, children: [
40
+ return /* @__PURE__ */ jsx(CmdkContext.Provider, { value: ctx, children: /* @__PURE__ */ jsxs("div", { ref: rootRef, className: cx("uix-cmdk", className), ...props, children: [
33
41
  /* @__PURE__ */ jsx(
34
42
  "input",
35
43
  {
@@ -0,0 +1,15 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+
4
+ /**
5
+ * Renders `children` only once they have been mounted for `delay` ms (HAR-2580). The client
6
+ * half of `Spinner delay` and `LoadingState delay`, so those stay server-renderable: a
7
+ * loader whose work ends inside the delay never appears. Inserting the loader after the
8
+ * delay is what announces it, as it always has.
9
+ */
10
+ declare function DelayedRender({ delay, children }: {
11
+ delay: number;
12
+ children?: ReactNode;
13
+ }): react.JSX.Element | null;
14
+
15
+ export { DelayedRender };
@@ -0,0 +1,10 @@
1
+ "use client";
2
+ import { Fragment, jsx } from "react/jsx-runtime";
3
+ import { useDelayedPending } from "../hooks/useDelayedPending.js";
4
+ function DelayedRender({ delay, children }) {
5
+ const shown = useDelayedPending(true, { delay, minVisible: 0 });
6
+ return shown ? /* @__PURE__ */ jsx(Fragment, { children }) : null;
7
+ }
8
+ export {
9
+ DelayedRender
10
+ };
@@ -3,6 +3,7 @@ import { SyntheticEvent, RefObject, KeyboardEvent, ReactNode } from 'react';
3
3
  import { ButtonProps } from './Button.js';
4
4
  import '../link.js';
5
5
  import './Kbd.js';
6
+ import './ButtonFeedback.js';
6
7
 
7
8
  /** The words of the "Discard changes?" step a `dirty` Drawer or Modal asks before it closes (HAR-2029). */
8
9
  interface DiscardPromptLabels {
@@ -4,6 +4,7 @@ import { DialogCloseEvent, DiscardPromptLabels } from './DiscardPrompt.js';
4
4
  import './Button.js';
5
5
  import '../link.js';
6
6
  import './Kbd.js';
7
+ import './ButtonFeedback.js';
7
8
 
8
9
  interface DrawerProps extends Omit<HTMLAttributes<HTMLDialogElement>, 'title'> {
9
10
  /** TENSOR RX-125 (UIX-04): translatable; English default. */
@@ -25,7 +25,7 @@ function Meter({ value = 0, tone, label, toneLabels, className, ...props }) {
25
25
  "div",
26
26
  {
27
27
  className: "uix-meter__fill",
28
- style: { width: `${pct}%` },
28
+ style: { "--val": pct / 100 },
29
29
  ...tone && tone !== "success" ? { "data-tone": tone } : {}
30
30
  }
31
31
  )
@@ -4,6 +4,7 @@ import { DialogCloseEvent, DiscardPromptLabels } from './DiscardPrompt.js';
4
4
  import './Button.js';
5
5
  import '../link.js';
6
6
  import './Kbd.js';
7
+ import './ButtonFeedback.js';
7
8
 
8
9
  interface ModalProps extends Omit<HTMLAttributes<HTMLDialogElement>, 'title'> {
9
10
  open: boolean;
@@ -28,7 +29,12 @@ interface ModalProps extends Omit<HTMLAttributes<HTMLDialogElement>, 'title'> {
28
29
  /** Accessible name for the close button (default "Close dialog"). */
29
30
  closeLabel?: string;
30
31
  role?: 'dialog' | 'alertdialog';
32
+ /**
33
+ * Open and close with no animation (`[data-uix-instant]`). For a keyboard-initiated or
34
+ * many-times-a-day dialog. A `CommandPalette` inside sets this on its own.
35
+ */
36
+ instant?: boolean;
31
37
  }
32
- declare function Modal({ open, onClose, dirty, pending, discardLabels, title, children, footer, className, closeLabel: closeLabelProp, role, onKeyDown, ...rest }: ModalProps): react.JSX.Element;
38
+ declare function Modal({ open, onClose, dirty, pending, discardLabels, title, children, footer, className, closeLabel: closeLabelProp, role, instant, onKeyDown, ...rest }: ModalProps): react.JSX.Element;
33
39
 
34
40
  export { Modal, type ModalProps };
@@ -17,6 +17,7 @@ function Modal({
17
17
  className,
18
18
  closeLabel: closeLabelProp,
19
19
  role = "dialog",
20
+ instant = false,
20
21
  onKeyDown,
21
22
  ...rest
22
23
  }) {
@@ -32,6 +33,7 @@ function Modal({
32
33
  className: cx("uix-dialog", className),
33
34
  role,
34
35
  "aria-labelledby": title != null ? titleId : void 0,
36
+ "data-uix-instant": instant ? "" : void 0,
35
37
  ...rest,
36
38
  onKeyDown: (event) => {
37
39
  onKeyDown?.(event);
@@ -13,7 +13,7 @@ function Progress({ value = 0, max = 100, indeterminate, label, className, ...pr
13
13
  "aria-label": label,
14
14
  className: cx("uix-progress", indeterminate && "uix-progress--indeterminate", className),
15
15
  ...props,
16
- children: /* @__PURE__ */ jsx("div", { className: "uix-progress__bar", style: indeterminate ? void 0 : { width: `${pct}%` } })
16
+ children: /* @__PURE__ */ jsx("div", { className: "uix-progress__bar", style: indeterminate ? void 0 : { "--val": pct / 100 } })
17
17
  }
18
18
  );
19
19
  }
@@ -16,6 +16,12 @@ interface SaveStatusProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children
16
16
  labels?: Partial<SaveStatusLabels>;
17
17
  /** Offered as a button only while `state` is `failed`. */
18
18
  onRetry?: () => void;
19
+ /**
20
+ * Milliseconds before "Saving…" shows (default 300; HAR-2580). Until then the previous words
21
+ * stay, so a fast save goes straight to "Saved" without a flash; once "Saving…" shows it
22
+ * stays at least 500 ms. `0` shows every state at once and holds none.
23
+ */
24
+ delay?: number;
19
25
  }
20
26
  /**
21
27
  * Inline save feedback over `.uix-save-status`: saving → saved → failed, with a retry.
@@ -27,6 +33,6 @@ interface SaveStatusProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children
27
33
  * rendering null while idle would silence the first "Saving…". The retry button sits
28
34
  * outside the region so its label is not read out on every change.
29
35
  */
30
- declare function SaveStatus({ state, labels, onRetry, className, ...props }: SaveStatusProps): react.JSX.Element;
36
+ declare function SaveStatus({ state, labels, onRetry, delay, className, ...props }: SaveStatusProps): react.JSX.Element;
31
37
 
32
38
  export { DEFAULT_SAVE_STATUS_LABELS, SaveStatus, type SaveStatusLabels, type SaveStatusProps, type SaveStatusState };
@@ -1,18 +1,27 @@
1
+ "use client";
1
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useRef } from "react";
2
4
  import { cx } from "../cx.js";
5
+ import { useDelayedPending } from "../hooks/useDelayedPending.js";
6
+ import { PENDING_DELAY_MS } from "../motion-timing.js";
3
7
  const DEFAULT_SAVE_STATUS_LABELS = {
4
8
  saving: "Saving\u2026",
5
9
  saved: "Saved",
6
10
  failed: "Could not save",
7
11
  retry: "Try again"
8
12
  };
9
- function SaveStatus({ state, labels, onRetry, className, ...props }) {
13
+ function SaveStatus({ state, labels, onRetry, delay = PENDING_DELAY_MS, className, ...props }) {
10
14
  const text = { ...DEFAULT_SAVE_STATUS_LABELS, ...labels };
11
- const message = state === "idle" ? "" : text[state];
12
- return /* @__PURE__ */ jsxs("div", { className: cx("uix-save-status", className), "data-state": state, ...props, children: [
15
+ const savingShown = useDelayedPending(state === "saving", { delay, minVisible: delay > 0 ? void 0 : 0 });
16
+ const settled = useRef(state === "saving" ? "idle" : state);
17
+ if (state !== "saving") settled.current = state;
18
+ const quiet = settled.current === "saved" ? "saved" : "idle";
19
+ const shown = savingShown ? "saving" : state === "saving" ? quiet : state;
20
+ const message = shown === "idle" ? "" : text[shown];
21
+ return /* @__PURE__ */ jsxs("div", { className: cx("uix-save-status", className), "data-state": shown, ...props, children: [
13
22
  /* @__PURE__ */ jsx("span", { className: "uix-save-status__icon", "aria-hidden": "true" }),
14
23
  /* @__PURE__ */ jsx("span", { className: "uix-save-status__text", role: "status", "aria-live": "polite", children: message }),
15
- state === "failed" && onRetry && /* @__PURE__ */ jsx("button", { type: "button", className: "uix-btn uix-btn--link uix-btn--sm uix-save-status__retry", onClick: onRetry, children: text.retry })
24
+ shown === "failed" && onRetry && /* @__PURE__ */ jsx("button", { type: "button", className: "uix-btn uix-btn--link uix-btn--sm uix-save-status__retry", onClick: onRetry, children: text.retry })
16
25
  ] });
17
26
  }
18
27
  export {
@@ -3,6 +3,7 @@ import { HTMLAttributes, Ref } from 'react';
3
3
  import { ButtonVariant } from './Button.js';
4
4
  import '../link.js';
5
5
  import './Kbd.js';
6
+ import './ButtonFeedback.js';
6
7
 
7
8
  /** The words of a `SearchBar` (HAR-2030); the field's own name is its `label`. */
8
9
  interface SearchBarLabels {
@@ -7,7 +7,13 @@ interface SpinnerProps extends HTMLAttributes<HTMLSpanElement> {
7
7
  accent?: boolean;
8
8
  /** Announced loading text (default "Loading…"). */
9
9
  label?: string;
10
+ /**
11
+ * Show the spinner only after it has been mounted this long: `true` is 300 ms, a number is
12
+ * milliseconds (HAR-2580). Work that ends sooner never flashes a spinner. To also hold it at
13
+ * least 500 ms once shown, mount it from `useDelayedPending(pending)` instead.
14
+ */
15
+ delay?: boolean | number;
10
16
  }
11
- declare function Spinner({ size, accent, label, className, ...props }: SpinnerProps): react.JSX.Element;
17
+ declare function Spinner({ delay, ...props }: SpinnerProps): react.JSX.Element;
12
18
 
13
19
  export { Spinner, type SpinnerProps };
@@ -1,6 +1,12 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
2
  import { cx } from "../cx.js";
3
- function Spinner({ size = "md", accent, label, className, ...props }) {
3
+ import { DelayedRender } from "./DelayedRender.js";
4
+ import { PENDING_DELAY_MS } from "../motion-timing.js";
5
+ function Spinner({ delay, ...props }) {
6
+ if (delay === void 0 || delay === false || delay === 0) return /* @__PURE__ */ jsx(SpinnerRing, { ...props });
7
+ return /* @__PURE__ */ jsx(DelayedRender, { delay: delay === true ? PENDING_DELAY_MS : delay, children: /* @__PURE__ */ jsx(SpinnerRing, { ...props }) });
8
+ }
9
+ function SpinnerRing({ size = "md", accent, label, className, ...props }) {
4
10
  return /* @__PURE__ */ jsx(
5
11
  "span",
6
12
  {
@@ -51,8 +51,14 @@ interface LoadingStateProps extends HTMLAttributes<HTMLDivElement> {
51
51
  density?: 'compact' | 'comfortable';
52
52
  /** Announced loading text (default "Loading"). */
53
53
  label?: string;
54
+ /**
55
+ * Show the skeleton only after it has been mounted this long: `true` is 300 ms, a number is
56
+ * milliseconds (HAR-2580). A first load that answers sooner goes straight to the content.
57
+ * Skeletons are for a first load; a refetch keeps its rows in a `BusyRegion`.
58
+ */
59
+ delay?: boolean | number;
54
60
  }
55
61
  /** A stack of skeleton rows for list/section loading. `role="status"`. */
56
- declare function LoadingState({ rows, density, label, className, ...props }: LoadingStateProps): react.JSX.Element;
62
+ declare function LoadingState({ delay, ...props }: LoadingStateProps): react.JSX.Element;
57
63
 
58
64
  export { type AccessStateProps, EmptyState, type EmptyStateProps, ErrorState, type ErrorStateProps, FilteredEmptyState, ForbiddenState, LoadingState, type LoadingStateProps, NotFoundState, Skeleton, type SkeletonProps, type StateVariant };
@@ -1,5 +1,7 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { cx } from "../cx.js";
3
+ import { DelayedRender } from "./DelayedRender.js";
4
+ import { PENDING_DELAY_MS } from "../motion-timing.js";
3
5
  function EmptyState({ icon, title, children, action, variant = "section", className, ...props }) {
4
6
  return /* @__PURE__ */ jsxs("div", { className: cx("uix-empty", `uix-empty--${variant}`, className), ...props, children: [
5
7
  icon != null && /* @__PURE__ */ jsx("div", { className: "uix-empty__icon", children: icon }),
@@ -51,7 +53,11 @@ function Skeleton({ variant = "block", width, height, className, style, ...props
51
53
  }
52
54
  );
53
55
  }
54
- function LoadingState({ rows = 3, density = "comfortable", label, className, ...props }) {
56
+ function LoadingState({ delay, ...props }) {
57
+ if (delay === void 0 || delay === false || delay === 0) return /* @__PURE__ */ jsx(SkeletonRows, { ...props });
58
+ return /* @__PURE__ */ jsx(DelayedRender, { delay: delay === true ? PENDING_DELAY_MS : delay, children: /* @__PURE__ */ jsx(SkeletonRows, { ...props }) });
59
+ }
60
+ function SkeletonRows({ rows = 3, density = "comfortable", label, className, ...props }) {
55
61
  return (
56
62
  // no aria-busy: busy=true tells AT the region isn't ready and suppresses the announcement (UIX-A11Y-4)
57
63
  /* @__PURE__ */ jsxs("div", { className: cx("uix-stack", density === "compact" && "uix-stack--compact", className), role: "status", ...props, children: [
@@ -7,6 +7,13 @@ interface TableWrapProps extends HTMLAttributes<HTMLDivElement> {
7
7
  * "N selected". Pass `useTable().announcement` straight through (UIX-A11Y-2).
8
8
  */
9
9
  announcement?: string;
10
+ /**
11
+ * The rows are being fetched again (a filter, sort, page or saved-view change; HAR-2582). The
12
+ * current rows stay and dim under an indeterminate bar along the top, and the wrap is
13
+ * `aria-busy`; after 300 ms, held at least 500 ms. Keep the previous rows while fetching
14
+ * (TanStack Query `placeholderData: keepPreviousData`); skeleton rows are for a first load.
15
+ */
16
+ refreshing?: boolean;
10
17
  children?: ReactNode;
11
18
  }
12
19
  declare const TableWrap: react.ForwardRefExoticComponent<TableWrapProps & react.RefAttributes<HTMLDivElement>>;
@@ -2,8 +2,10 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef } from "react";
3
3
  import { cx } from "../cx.js";
4
4
  import { highlightSegments } from "../table-engine.js";
5
- const TableWrap = forwardRef(function TableWrap2({ announcement, children, className, ...props }, ref) {
6
- return /* @__PURE__ */ jsxs("div", { ref, className: cx("uix-table-wrap", className), ...props, children: [
5
+ import { BusyBar } from "./BusyBar.js";
6
+ const TableWrap = forwardRef(function TableWrap2({ announcement, refreshing, children, className, ...props }, ref) {
7
+ return /* @__PURE__ */ jsxs("div", { ref, className: cx("uix-table-wrap", className), "aria-busy": refreshing || void 0, ...props, children: [
8
+ refreshing !== void 0 && /* @__PURE__ */ jsx(BusyBar, { busy: refreshing }),
7
9
  announcement != null && /* @__PURE__ */ jsx("div", { role: "status", className: "uix-visually-hidden", children: announcement }),
8
10
  children
9
11
  ] });