@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.
- package/README.md +24 -0
- package/dist/components/Button.d.ts +19 -1
- package/dist/components/Button.js +13 -8
- package/dist/components/DescriptionList.d.ts +11 -0
- package/dist/components/DiscardPrompt.d.ts +54 -0
- package/dist/components/DiscardPrompt.js +245 -0
- package/dist/components/Drawer.d.ts +19 -3
- package/dist/components/Drawer.js +60 -21
- package/dist/components/EditorialHome.d.ts +1 -1
- package/dist/components/Field.d.ts +7 -1
- package/dist/components/Field.js +5 -4
- package/dist/components/Input.d.ts +5 -0
- package/dist/components/Input.js +2 -1
- package/dist/components/Kbd.d.ts +20 -1
- package/dist/components/Kbd.js +30 -7
- package/dist/components/Menu.d.ts +11 -4
- package/dist/components/Menu.js +17 -3
- package/dist/components/MetricInput.d.ts +8 -1
- package/dist/components/MetricInput.js +11 -3
- package/dist/components/Modal.d.ts +15 -1
- package/dist/components/Modal.js +58 -13
- package/dist/components/OrgChart.d.ts +121 -0
- package/dist/components/OrgChart.js +683 -0
- package/dist/components/SearchBar.d.ts +67 -0
- package/dist/components/SearchBar.js +117 -0
- package/dist/components/Select.js +5 -4
- package/dist/components/ShortcutHint.d.ts +29 -0
- package/dist/components/ShortcutHint.js +149 -0
- package/dist/components/Tooltip.d.ts +10 -1
- package/dist/components/Tooltip.js +15 -4
- package/dist/emoji.cjs +108 -31
- package/dist/hooks/useAnchoredPosition.d.ts +13 -1
- package/dist/hooks/useAnchoredPosition.js +19 -11
- package/dist/hooks/useDialog.d.ts +6 -1
- package/dist/hooks/useDialog.js +10 -1
- package/dist/index.cjs +3911 -2567
- package/dist/index.d.cts +340 -39
- package/dist/index.d.ts +4 -1
- package/dist/index.js +11 -1
- package/dist/labels-context.d.ts +11 -0
- package/dist/rich-text.cjs +17 -10
- 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
|
-
|
|
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__ */
|
|
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)
|
|
12
|
-
*
|
|
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({
|
|
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
|
|
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(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 (
|
|
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 };
|
package/dist/components/Field.js
CHANGED
|
@@ -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 {
|