@tensor_1/react 2.46.0 → 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.
- package/README.md +39 -0
- package/dist/components/BusyBar.d.ts +13 -0
- package/dist/components/BusyBar.js +10 -0
- package/dist/components/BusyRegion.d.ts +18 -0
- package/dist/components/BusyRegion.js +12 -0
- package/dist/components/Button.d.ts +21 -0
- package/dist/components/Button.js +5 -3
- package/dist/components/ButtonFeedback.d.ts +30 -0
- package/dist/components/ButtonFeedback.js +78 -0
- package/dist/components/DelayedRender.d.ts +15 -0
- package/dist/components/DelayedRender.js +10 -0
- package/dist/components/DiscardPrompt.d.ts +1 -0
- package/dist/components/Drawer.d.ts +1 -0
- package/dist/components/Modal.d.ts +1 -0
- package/dist/components/SaveStatus.d.ts +7 -1
- package/dist/components/SaveStatus.js +13 -4
- package/dist/components/SearchBar.d.ts +1 -0
- package/dist/components/Spinner.d.ts +7 -1
- package/dist/components/Spinner.js +7 -1
- package/dist/components/States.d.ts +7 -1
- package/dist/components/States.js +7 -1
- package/dist/components/Table.d.ts +7 -0
- package/dist/components/Table.js +4 -2
- package/dist/hooks/useDelayedPending.d.ts +22 -0
- package/dist/hooks/useDelayedPending.js +35 -0
- package/dist/index.cjs +2593 -2428
- package/dist/index.d.cts +101 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +10 -0
- package/dist/labels-context.d.ts +3 -0
- package/dist/motion-timing.d.ts +8 -0
- package/dist/motion-timing.js +8 -0
- 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.
|
|
@@ -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 =
|
|
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-
|
|
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
|
+
};
|
|
@@ -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. */
|
|
@@ -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;
|
|
@@ -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
|
|
12
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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({
|
|
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>>;
|
package/dist/components/Table.js
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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
|
] });
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
interface DelayedPendingOptions {
|
|
2
|
+
/** Wait this long before showing anything (default 300 ms). `0` shows at once. */
|
|
3
|
+
delay?: number;
|
|
4
|
+
/** Once shown, keep showing at least this long (default 500 ms). */
|
|
5
|
+
minVisible?: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Whether to show a pending indicator for `pending` (motion standard §2.3): nothing for the
|
|
9
|
+
* first 300 ms, so a fast action never flashes a spinner; once shown, held at least 500 ms
|
|
10
|
+
* after it appeared, so it never flickers off the moment the work ends.
|
|
11
|
+
*
|
|
12
|
+
* ```tsx
|
|
13
|
+
* const showSpinner = useDelayedPending(query.isFetching);
|
|
14
|
+
* {showSpinner && <Spinner />}
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Use the raw `pending` for behaviour (blocking a second submit); use the result for what
|
|
18
|
+
* people see. Returns `false` on the server and on the first client render unless `delay` is 0.
|
|
19
|
+
*/
|
|
20
|
+
declare function useDelayedPending(pending: boolean, { delay, minVisible }?: DelayedPendingOptions): boolean;
|
|
21
|
+
|
|
22
|
+
export { type DelayedPendingOptions, useDelayedPending };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { PENDING_DELAY_MS, PENDING_MIN_VISIBLE_MS } from "../motion-timing.js";
|
|
4
|
+
function useDelayedPending(pending, { delay = PENDING_DELAY_MS, minVisible = PENDING_MIN_VISIBLE_MS } = {}) {
|
|
5
|
+
const [shown, setShown] = useState(() => pending && delay <= 0);
|
|
6
|
+
const shownAt = useRef(shown ? now() : 0);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (pending) {
|
|
9
|
+
if (shown) return void 0;
|
|
10
|
+
const show = () => {
|
|
11
|
+
shownAt.current = now();
|
|
12
|
+
setShown(true);
|
|
13
|
+
};
|
|
14
|
+
if (delay <= 0) {
|
|
15
|
+
show();
|
|
16
|
+
return void 0;
|
|
17
|
+
}
|
|
18
|
+
const timer2 = setTimeout(show, delay);
|
|
19
|
+
return () => clearTimeout(timer2);
|
|
20
|
+
}
|
|
21
|
+
if (!shown) return void 0;
|
|
22
|
+
const remaining = minVisible - (now() - shownAt.current);
|
|
23
|
+
if (remaining <= 0) {
|
|
24
|
+
setShown(false);
|
|
25
|
+
return void 0;
|
|
26
|
+
}
|
|
27
|
+
const timer = setTimeout(() => setShown(false), remaining);
|
|
28
|
+
return () => clearTimeout(timer);
|
|
29
|
+
}, [pending, shown, delay, minVisible]);
|
|
30
|
+
return shown;
|
|
31
|
+
}
|
|
32
|
+
const now = () => Date.now();
|
|
33
|
+
export {
|
|
34
|
+
useDelayedPending
|
|
35
|
+
};
|