@tensor_1/react 2.40.2 → 2.42.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 (41) hide show
  1. package/dist/components/Button.d.ts +19 -1
  2. package/dist/components/Button.js +13 -8
  3. package/dist/components/DescriptionList.d.ts +11 -0
  4. package/dist/components/DiscardPrompt.d.ts +54 -0
  5. package/dist/components/DiscardPrompt.js +245 -0
  6. package/dist/components/Drawer.d.ts +19 -3
  7. package/dist/components/Drawer.js +60 -21
  8. package/dist/components/EditorialHome.d.ts +1 -1
  9. package/dist/components/Field.d.ts +7 -1
  10. package/dist/components/Field.js +5 -4
  11. package/dist/components/Input.d.ts +5 -0
  12. package/dist/components/Input.js +2 -1
  13. package/dist/components/Kbd.d.ts +20 -1
  14. package/dist/components/Kbd.js +30 -7
  15. package/dist/components/Menu.d.ts +11 -4
  16. package/dist/components/Menu.js +17 -3
  17. package/dist/components/MetricInput.d.ts +8 -1
  18. package/dist/components/MetricInput.js +11 -3
  19. package/dist/components/Modal.d.ts +15 -1
  20. package/dist/components/Modal.js +58 -13
  21. package/dist/components/SearchBar.d.ts +67 -0
  22. package/dist/components/SearchBar.js +117 -0
  23. package/dist/components/Select.js +5 -4
  24. package/dist/components/ShortcutHint.d.ts +29 -0
  25. package/dist/components/ShortcutHint.js +149 -0
  26. package/dist/components/Table.d.ts +7 -1
  27. package/dist/components/Table.js +2 -0
  28. package/dist/components/Tooltip.d.ts +10 -1
  29. package/dist/components/Tooltip.js +15 -4
  30. package/dist/emoji.cjs +108 -31
  31. package/dist/hooks/useAnchoredPosition.d.ts +13 -1
  32. package/dist/hooks/useAnchoredPosition.js +19 -11
  33. package/dist/hooks/useDialog.d.ts +6 -1
  34. package/dist/hooks/useDialog.js +10 -1
  35. package/dist/index.cjs +3186 -2521
  36. package/dist/index.d.cts +228 -40
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.js +6 -1
  39. package/dist/labels-context.d.ts +8 -0
  40. package/dist/rich-text.cjs +17 -10
  41. package/package.json +1 -1
@@ -7,6 +7,25 @@ interface KbdProps extends HTMLAttributes<HTMLElement> {
7
7
  /** One keyboard key over `.uix-kbd` (HAR-1361; TENSOR C14, MOTUS B-A16). */
8
8
  declare function Kbd({ className, children, ...props }: KbdProps): react.JSX.Element;
9
9
  type KbdPlatform = 'mac' | 'other';
10
+ /**
11
+ * The platform for glyphs and `aria-keyshortcuts`: `platform` when given, else the browser's. The
12
+ * server and the hydrating render use `other`; a component mounted later (a menu row on open)
13
+ * reads the platform at once, so it never flashes Ctrl before ⌘.
14
+ */
15
+ declare function useKbdPlatform(platform?: KbdPlatform): KbdPlatform;
16
+ /**
17
+ * A keyboard shortcut (HAR-2031): the keys pressed together, in `KbdCombo`'s names, e.g.
18
+ * `['Mod', 'K']`, `['Shift', '?']` or `['A']`. `Mod` is ⌘ on Apple and Ctrl elsewhere.
19
+ */
20
+ type KeyShortcut = readonly string[];
21
+ /**
22
+ * The `aria-keyshortcuts` value of a shortcut on a platform (HAR-2031): `['Mod', 'K']` is
23
+ * `Meta+K` on Apple and `Control+K` elsewhere; a single letter is upper-cased (`['a']` is `A`).
24
+ * For a control the kit does not render.
25
+ */
26
+ declare function ariaKeyShortcuts(keys: KeyShortcut, platform: KbdPlatform): string;
27
+ /** A `shortcut` prop that is key names rather than a node (HAR-2031). */
28
+ declare const isKeyShortcut: (value: unknown) => value is KeyShortcut;
10
29
  interface KbdComboProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'> {
11
30
  /**
12
31
  * Keys in order, e.g. `['Mod', 'K']`. `Mod` is ⌘ on Apple and Ctrl elsewhere; `Shift`,
@@ -24,4 +43,4 @@ interface KbdComboProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'
24
43
  */
25
44
  declare function KbdCombo({ keys, platform, keyLabels, className, ...props }: KbdComboProps): react.JSX.Element;
26
45
 
27
- export { Kbd, KbdCombo, type KbdComboProps, type KbdPlatform, type KbdProps };
46
+ export { Kbd, KbdCombo, type KbdComboProps, type KbdPlatform, type KbdProps, type KeyShortcut, ariaKeyShortcuts, isKeyShortcut, useKbdPlatform };
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { useEffect, useState } from "react";
3
+ import { useSyncExternalStore } from "react";
4
4
  import { cx } from "../cx.js";
5
5
  function Kbd({ className, children, ...props }) {
6
6
  return /* @__PURE__ */ jsx("kbd", { className: cx("uix-kbd", className), ...props, children });
@@ -12,6 +12,11 @@ const KEYS = {
12
12
  Alt: { mac: ["\u2325", "Option"], other: ["Alt", "Alt"] },
13
13
  Enter: { mac: ["\u21B5", "Enter"], other: ["Enter", "Enter"] },
14
14
  Escape: { mac: ["Esc", "Escape"], other: ["Esc", "Escape"] },
15
+ Esc: { mac: ["Esc", "Escape"], other: ["Esc", "Escape"] },
16
+ Delete: { mac: ["Del", "Delete"], other: ["Del", "Delete"] },
17
+ Del: { mac: ["Del", "Delete"], other: ["Del", "Delete"] },
18
+ Space: { mac: ["Space", "Space"], other: ["Space", "Space"] },
19
+ " ": { mac: ["Space", "Space"], other: ["Space", "Space"] },
15
20
  Backspace: { mac: ["\u232B", "Backspace"], other: ["Backspace", "Backspace"] },
16
21
  Tab: { mac: ["\u21E5", "Tab"], other: ["Tab", "Tab"] },
17
22
  ArrowUp: { mac: ["\u2191", "Up arrow"], other: ["\u2191", "Up arrow"] },
@@ -24,12 +29,27 @@ const detectPlatform = () => {
24
29
  const nav = navigator;
25
30
  return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform ?? navigator.platform ?? "") ? "mac" : "other";
26
31
  };
32
+ const noSubscription = () => () => {
33
+ };
34
+ const serverPlatform = () => "other";
35
+ function useKbdPlatform(platform) {
36
+ const detected = useSyncExternalStore(noSubscription, detectPlatform, serverPlatform);
37
+ return platform ?? detected;
38
+ }
39
+ const ARIA_KEYS = {
40
+ Mod: { mac: "Meta", other: "Control" },
41
+ Ctrl: { mac: "Control", other: "Control" },
42
+ Esc: { mac: "Escape", other: "Escape" },
43
+ Del: { mac: "Delete", other: "Delete" },
44
+ " ": { mac: "Space", other: "Space" },
45
+ "+": { mac: "Plus", other: "Plus" }
46
+ };
47
+ function ariaKeyShortcuts(keys, platform) {
48
+ return keys.map((key) => ARIA_KEYS[key]?.[platform] ?? (key.length === 1 ? key.toUpperCase() : key)).join("+");
49
+ }
50
+ const isKeyShortcut = (value) => Array.isArray(value) && value.length > 0 && value.every((key) => typeof key === "string");
27
51
  function KbdCombo({ keys, platform, keyLabels, className, ...props }) {
28
- const [detected, setDetected] = useState("other");
29
- useEffect(() => {
30
- if (!platform) setDetected(detectPlatform());
31
- }, [platform]);
32
- const active = platform ?? detected;
52
+ const active = useKbdPlatform(platform);
33
53
  const parts = keys.map((key) => {
34
54
  const known = KEYS[key]?.[active];
35
55
  return { glyph: known?.[0] ?? key, spoken: keyLabels?.[key] ?? known?.[1] ?? key };
@@ -41,5 +61,8 @@ function KbdCombo({ keys, platform, keyLabels, className, ...props }) {
41
61
  }
42
62
  export {
43
63
  Kbd,
44
- KbdCombo
64
+ KbdCombo,
65
+ ariaKeyShortcuts,
66
+ isKeyShortcut,
67
+ useKbdPlatform
45
68
  };
@@ -2,6 +2,7 @@ import * as react from 'react';
2
2
  import { ReactElement, ReactNode } from 'react';
3
3
  import { UixRenderLink } from '../link.js';
4
4
  import { Placement } from '../overlay-position.js';
5
+ import { KeyShortcut } from './Kbd.js';
5
6
 
6
7
  /** `id` and `data-*` for the item's own element (HAR-1629): a stable hook for tests, journeys and analytics. */
7
8
  interface MenuItemAttributes {
@@ -40,8 +41,12 @@ interface MenuItemProps extends MenuItemAttributes {
40
41
  href?: string;
41
42
  renderLink?: UixRenderLink;
42
43
  icon?: ReactNode;
43
- /** Shortcut hint at the end of the row, e.g. `<KbdCombo keys={['Mod', 'D']} />`. */
44
- shortcut?: ReactNode;
44
+ /**
45
+ * The row's keyboard shortcut, at its end. Key names (`['Mod', 'D']`, `['A']`) render with
46
+ * `KbdCombo` and set the row's `aria-keyshortcuts` for the viewer's platform (HAR-2031); any
47
+ * other node renders as given.
48
+ */
49
+ shortcut?: KeyShortcut | ReactNode;
45
50
  tone?: 'default' | 'danger';
46
51
  disabled?: boolean;
47
52
  className?: string;
@@ -70,7 +75,8 @@ interface MenuItemRadioProps extends MenuItemAttributes {
70
75
  closeOnSelect?: boolean;
71
76
  children?: ReactNode;
72
77
  icon?: ReactNode;
73
- shortcut?: ReactNode;
78
+ /** Key names (`['Shift', 'W']`) or a node; see `MenuItemProps.shortcut`. */
79
+ shortcut?: KeyShortcut | ReactNode;
74
80
  disabled?: boolean;
75
81
  className?: string;
76
82
  }
@@ -83,7 +89,8 @@ interface MenuItemCheckboxProps extends MenuItemAttributes {
83
89
  closeOnSelect?: boolean;
84
90
  children?: ReactNode;
85
91
  icon?: ReactNode;
86
- shortcut?: ReactNode;
92
+ /** Key names (`['Shift', 'W']`) or a node; see `MenuItemProps.shortcut`. */
93
+ shortcut?: KeyShortcut | ReactNode;
87
94
  disabled?: boolean;
88
95
  className?: string;
89
96
  }
@@ -5,6 +5,16 @@ import { cx } from "../cx.js";
5
5
  import { Popover } from "./Popover.js";
6
6
  import { CheckIcon } from "../icons/components.js";
7
7
  import { renderUixLink } from "../link.js";
8
+ import { KbdCombo, ariaKeyShortcuts, isKeyShortcut, useKbdPlatform } from "./Kbd.js";
9
+ function useRowShortcut(shortcut) {
10
+ const platform = useKbdPlatform();
11
+ if (shortcut == null) return { node: null, aria: {} };
12
+ if (!isKeyShortcut(shortcut)) return { node: /* @__PURE__ */ jsx("span", { className: "uix-menu__shortcut", children: shortcut }), aria: {} };
13
+ return {
14
+ node: /* @__PURE__ */ jsx("span", { className: "uix-menu__shortcut", children: /* @__PURE__ */ jsx(KbdCombo, { keys: [...shortcut], platform }) }),
15
+ aria: { "aria-keyshortcuts": ariaKeyShortcuts(shortcut, platform) }
16
+ };
17
+ }
8
18
  const MenuContext = createContext(null);
9
19
  const MenuRadioContext = createContext(null);
10
20
  const ITEM = '[role^="menuitem"]:not([aria-disabled="true"])';
@@ -150,18 +160,20 @@ function MenuItem({ children, onSelect, href, renderLink, icon, shortcut, tone =
150
160
  const menu = useContext(MenuContext);
151
161
  const classes = cx("uix-menu__item", tone === "danger" && "uix-menu__item--danger", className);
152
162
  const attributes = itemAttributes(rest);
163
+ const keys = useRowShortcut(shortcut);
153
164
  const body = /* @__PURE__ */ jsxs(Fragment, { children: [
154
165
  icon != null && /* @__PURE__ */ jsx("span", { className: "uix-menu__icon", "aria-hidden": "true", children: icon }),
155
166
  /* @__PURE__ */ jsx("span", { className: "uix-menu__text", children }),
156
- shortcut != null && /* @__PURE__ */ jsx("span", { className: "uix-menu__shortcut", children: shortcut })
167
+ keys.node
157
168
  ] });
158
169
  if (href != null && !disabled) {
159
- return /* @__PURE__ */ jsx(Fragment, { children: renderUixLink(renderLink, { ...attributes, href, role: "menuitem", tabIndex: -1, className: classes, onClick: () => menu?.close(false), children: body }) });
170
+ return /* @__PURE__ */ jsx(Fragment, { children: renderUixLink(renderLink, { ...attributes, ...keys.aria, href, role: "menuitem", tabIndex: -1, className: classes, onClick: () => menu?.close(false), children: body }) });
160
171
  }
161
172
  return /* @__PURE__ */ jsx(
162
173
  "button",
163
174
  {
164
175
  ...attributes,
176
+ ...keys.aria,
165
177
  type: "button",
166
178
  role: "menuitem",
167
179
  tabIndex: -1,
@@ -189,10 +201,12 @@ function CheckableItem({
189
201
  ...rest
190
202
  }) {
191
203
  const menu = useContext(MenuContext);
204
+ const keys = useRowShortcut(shortcut);
192
205
  return /* @__PURE__ */ jsxs(
193
206
  "button",
194
207
  {
195
208
  ...itemAttributes(rest),
209
+ ...keys.aria,
196
210
  type: "button",
197
211
  role,
198
212
  "aria-checked": checked,
@@ -216,7 +230,7 @@ function CheckableItem({
216
230
  /* @__PURE__ */ jsx("span", { className: "uix-menu__check", "aria-hidden": "true", children: checked && /* @__PURE__ */ jsx(CheckIcon, {}) }),
217
231
  icon != null && /* @__PURE__ */ jsx("span", { className: "uix-menu__icon", "aria-hidden": "true", children: icon }),
218
232
  /* @__PURE__ */ jsx("span", { className: "uix-menu__text", children }),
219
- shortcut != null && /* @__PURE__ */ jsx("span", { className: "uix-menu__shortcut", children: shortcut })
233
+ keys.node
220
234
  ]
221
235
  }
222
236
  );
@@ -10,6 +10,13 @@ interface MetricInputProps {
10
10
  label: string;
11
11
  invalid?: boolean;
12
12
  errorMessage?: string;
13
+ /**
14
+ * A required value (HAR-1819): the label shows the same marker as `Field`, and the input
15
+ * gets native `required` and `aria-required="true"`.
16
+ */
17
+ required?: boolean;
18
+ /** Guidance under the control (a range, a unit rule), tied to the input by `aria-describedby`. */
19
+ hint?: string;
13
20
  disabled?: boolean;
14
21
  readOnly?: boolean;
15
22
  parse?: (input: string) => number | null;
@@ -18,6 +25,6 @@ interface MetricInputProps {
18
25
  id?: string;
19
26
  }
20
27
  /** Accessible numeric recipe composed from Input and the UIx stepper controls. */
21
- declare function MetricInput({ value, onValueChange, min, max, step, unit, label, invalid, errorMessage, disabled, readOnly, parse, format, className, id: providedId, }: MetricInputProps): react.JSX.Element;
28
+ declare function MetricInput({ value, onValueChange, min, max, step, unit, label, invalid, errorMessage, required, hint, disabled, readOnly, parse, format, className, id: providedId, }: MetricInputProps): react.JSX.Element;
22
29
 
23
30
  export { MetricInput, type MetricInputProps };
@@ -15,6 +15,8 @@ function MetricInput({
15
15
  label,
16
16
  invalid,
17
17
  errorMessage,
18
+ required,
19
+ hint,
18
20
  disabled,
19
21
  readOnly,
20
22
  parse = parseMetricValue,
@@ -25,6 +27,9 @@ function MetricInput({
25
27
  const generatedId = useId();
26
28
  const id = providedId ?? generatedId;
27
29
  const errorId = `${id}-error`;
30
+ const hintId = `${id}-hint`;
31
+ const showError = Boolean(invalid && errorMessage);
32
+ const describedBy = [hint ? hintId : null, showError ? errorId : null].filter(Boolean).join(" ") || void 0;
28
33
  const [text, setText] = useState(() => format(value));
29
34
  useEffect(() => setText(format(value)), [value, format]);
30
35
  const commit = (input) => {
@@ -53,7 +58,7 @@ function MetricInput({
53
58
  setText(parsed === null && event.currentTarget.value.trim() !== "" ? format(value) : format(parsed));
54
59
  };
55
60
  return /* @__PURE__ */ jsxs("div", { className: cx("uix-metric-input", invalid && "uix-metric-input--invalid", className), children: [
56
- /* @__PURE__ */ jsx("label", { className: "uix-field__label", htmlFor: id, children: label }),
61
+ /* @__PURE__ */ jsx("label", { className: "uix-field__label", htmlFor: id, "data-required": required || void 0, children: label }),
57
62
  /* @__PURE__ */ jsxs("div", { className: "uix-metric-input__control", children: [
58
63
  /* @__PURE__ */ jsx(
59
64
  Input,
@@ -68,7 +73,9 @@ function MetricInput({
68
73
  "aria-valuenow": value ?? void 0,
69
74
  "aria-valuemin": min,
70
75
  "aria-valuemax": max,
71
- "aria-describedby": invalid && errorMessage ? errorId : void 0,
76
+ "aria-describedby": describedBy,
77
+ required,
78
+ "aria-required": required || void 0,
72
79
  invalid,
73
80
  disabled,
74
81
  readOnly
@@ -84,7 +91,8 @@ function MetricInput({
84
91
  "Unit: ",
85
92
  unit
86
93
  ] }),
87
- invalid && errorMessage && /* @__PURE__ */ jsx("span", { id: errorId, className: "uix-field__error", children: errorMessage })
94
+ hint && /* @__PURE__ */ jsx("span", { id: hintId, className: "uix-field__hint", children: hint }),
95
+ showError && /* @__PURE__ */ jsx("span", { id: errorId, className: "uix-field__error", children: errorMessage })
88
96
  ] });
89
97
  }
90
98
  export {
@@ -1,9 +1,23 @@
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 ModalProps extends Omit<HTMLAttributes<HTMLDialogElement>, 'title'> {
5
9
  open: boolean;
6
10
  onClose?: () => void;
11
+ /**
12
+ * The dialog holds input that is not saved (HAR-2029). Escape, the close button and a
13
+ * `DialogClose` then open an anchored "Discard changes?" step with focus on "Keep editing"
14
+ * instead of calling `onClose`; "Discard" calls it. A clean dialog closes at once.
15
+ */
16
+ dirty?: boolean;
17
+ /** A save is in flight: Escape, the close button and a `DialogClose` do nothing. */
18
+ pending?: boolean;
19
+ /** The words of the discard step. Default: `UixLabelsProvider` `discardPrompt`, then English. */
20
+ discardLabels?: Partial<DiscardPromptLabels>;
7
21
  title?: ReactNode;
8
22
  children?: ReactNode;
9
23
  footer?: ReactNode;
@@ -11,6 +25,6 @@ interface ModalProps extends Omit<HTMLAttributes<HTMLDialogElement>, 'title'> {
11
25
  closeLabel?: string;
12
26
  role?: 'dialog' | 'alertdialog';
13
27
  }
14
- declare function Modal({ open, onClose, title, children, footer, className, closeLabel: closeLabelProp, role, ...rest }: ModalProps): react.JSX.Element;
28
+ declare function Modal({ open, onClose, dirty, pending, discardLabels, title, children, footer, className, closeLabel: closeLabelProp, role, onKeyDown, ...rest }: ModalProps): react.JSX.Element;
15
29
 
16
30
  export { Modal, type ModalProps };
@@ -1,23 +1,68 @@
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";
6
+ import { useGuardedDialog } from "./DiscardPrompt.js";
7
7
  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" }) });
8
- function Modal({ open, onClose, title, children, footer, className, closeLabel: closeLabelProp, role = "dialog", ...rest }) {
8
+ function Modal({
9
+ open,
10
+ onClose,
11
+ dirty = false,
12
+ pending = false,
13
+ discardLabels,
14
+ title,
15
+ children,
16
+ footer,
17
+ className,
18
+ closeLabel: closeLabelProp,
19
+ role = "dialog",
20
+ onKeyDown,
21
+ ...rest
22
+ }) {
9
23
  const uixLabels = useUixLabels();
10
24
  const closeLabel = closeLabelProp ?? uixLabels.modal?.close ?? "Close dialog";
11
- const ref = useDialog(open, onClose);
25
+ const closeRef = useRef(null);
26
+ const guard = useGuardedDialog({ open, onClose, dirty, pending, labels: discardLabels, closeRef });
12
27
  const titleId = useId();
13
- return /* @__PURE__ */ jsxs("dialog", { ref, className: cx("uix-dialog", className), role, "aria-labelledby": title != null ? titleId : void 0, ...rest, children: [
14
- (title != null || onClose) && /* @__PURE__ */ jsxs("div", { className: "uix-dialog__header", children: [
15
- title && /* @__PURE__ */ jsx("h2", { className: "uix-dialog__title", id: titleId, children: title }),
16
- onClose && /* @__PURE__ */ jsx("button", { type: "button", className: "uix-dialog__close", onClick: onClose, "aria-label": closeLabel, children: /* @__PURE__ */ jsx(CloseIcon, {}) })
17
- ] }),
18
- children != null && /* @__PURE__ */ jsx("div", { className: "uix-dialog__body", children }),
19
- footer != null && /* @__PURE__ */ jsx("div", { className: "uix-dialog__footer", children: footer })
20
- ] });
28
+ return /* @__PURE__ */ jsxs(
29
+ "dialog",
30
+ {
31
+ ref: guard.ref,
32
+ className: cx("uix-dialog", className),
33
+ role,
34
+ "aria-labelledby": title != null ? titleId : void 0,
35
+ ...rest,
36
+ onKeyDown: (event) => {
37
+ onKeyDown?.(event);
38
+ guard.onKeyDown(event);
39
+ },
40
+ children: [
41
+ guard.provide(
42
+ /* @__PURE__ */ jsxs(Fragment, { children: [
43
+ (title != null || onClose) && /* @__PURE__ */ jsxs("div", { className: "uix-dialog__header", children: [
44
+ title && /* @__PURE__ */ jsx("h2", { className: "uix-dialog__title", id: titleId, children: title }),
45
+ onClose && /* @__PURE__ */ jsx(
46
+ "button",
47
+ {
48
+ ref: closeRef,
49
+ type: "button",
50
+ className: "uix-dialog__close",
51
+ onClick: () => guard.requestClose(),
52
+ "aria-label": closeLabel,
53
+ "aria-disabled": pending || void 0,
54
+ children: /* @__PURE__ */ jsx(CloseIcon, {})
55
+ }
56
+ )
57
+ ] }),
58
+ children != null && /* @__PURE__ */ jsx("div", { className: "uix-dialog__body", children }),
59
+ footer != null && /* @__PURE__ */ jsx("div", { className: "uix-dialog__footer", children: footer })
60
+ ] })
61
+ ),
62
+ guard.prompt
63
+ ]
64
+ }
65
+ );
21
66
  }
22
67
  export {
23
68
  Modal
@@ -0,0 +1,67 @@
1
+ import * as react from 'react';
2
+ import { HTMLAttributes, Ref } from 'react';
3
+ import { ButtonVariant } from './Button.js';
4
+ import '../link.js';
5
+ import './Kbd.js';
6
+
7
+ /** The words of a `SearchBar` (HAR-2030); the field's own name is its `label`. */
8
+ interface SearchBarLabels {
9
+ /** Accessible name of the clear button. */
10
+ clear: string;
11
+ /** Text of the submit button (`submitButton`). */
12
+ submit: string;
13
+ }
14
+ interface SearchBarProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onSubmit' | 'onChange' | 'defaultValue'> {
15
+ /**
16
+ * Accessible name of the field and of its search landmark, e.g. "Search services". It is not
17
+ * shown (the placeholder is not a label): the field shows `placeholder`.
18
+ */
19
+ label: string;
20
+ /** The text in the field (controlled). Leave it out, with `defaultValue`, for an uncontrolled field. */
21
+ value?: string;
22
+ defaultValue?: string;
23
+ /** Called on every edit, and with `''` when the field is cleared. */
24
+ onValueChange?: (value: string) => void;
25
+ /** Enter in the field, or the submit button: search for this text. */
26
+ onSubmit?: (value: string) => void;
27
+ /** After the clear button or Escape emptied the field. Focus stays in the field. */
28
+ onClear?: () => void;
29
+ placeholder?: string;
30
+ /** `md` (default) for a header or a toolbar; `lg` for a page-level or public search: 44 px, the touch floor. */
31
+ size?: 'md' | 'lg';
32
+ /** A submit button after the field, with `labels.submit`. Enter submits either way. */
33
+ submitButton?: boolean;
34
+ /** The submit button's variant. Default `primary`. */
35
+ submitVariant?: ButtonVariant;
36
+ /**
37
+ * A `search` landmark named by `label` (default). Pass `false` for a field that filters a list
38
+ * or a table, where one more landmark per toolbar would crowd the landmark list. Never wrap it
39
+ * in a `<search>` or `role="search"` of your own: that nests two landmarks.
40
+ */
41
+ landmark?: boolean;
42
+ /** Ids of elements that describe the field (a hint, a result count), as its `aria-describedby`. */
43
+ inputDescribedBy?: string;
44
+ /** The words. Default: `UixLabelsProvider` `searchBar`, then English. */
45
+ labels?: Partial<SearchBarLabels>;
46
+ /** The field's `name`. */
47
+ name?: string;
48
+ /** The field's `id`, e.g. for a "/" shortcut that focuses it. Default: generated. */
49
+ inputId?: string;
50
+ inputRef?: Ref<HTMLInputElement>;
51
+ autoFocus?: boolean;
52
+ disabled?: boolean;
53
+ }
54
+ /**
55
+ * One search field look and behaviour (HAR-2030; TENSOR audit UXC-02) over the `.uix-searchbar`
56
+ * contract: a leading magnifier, a clear button while there is text, Enter to search, Escape to
57
+ * clear (an empty field lets Escape through, so it still closes the dialog it sits in). It is a
58
+ * `search` landmark named by `label`.
59
+ *
60
+ * It is not a `<form>`, so it can sit inside one: Enter calls `onSubmit` and never submits a
61
+ * surrounding form. For suggestions while typing, use `SearchSuggest`. Inside an overlay that
62
+ * takes Escape itself before the field (a ConfirmPopover, the table's view menu), Escape closes
63
+ * the overlay rather than clearing the text.
64
+ */
65
+ declare function SearchBar({ label, value: valueProp, defaultValue, onValueChange, onSubmit, onClear, placeholder, size, submitButton, submitVariant, landmark, inputDescribedBy, labels: labelsProp, name, inputId, inputRef, autoFocus, disabled, className, ...props }: SearchBarProps): react.JSX.Element;
66
+
67
+ export { SearchBar, type SearchBarLabels, type SearchBarProps };
@@ -0,0 +1,117 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useId, useRef, useState } from "react";
4
+ import { cx } from "../cx.js";
5
+ import { useUixLabels } from "../labels-context.js";
6
+ import { Button } from "./Button.js";
7
+ const DEFAULT_SEARCH_BAR_LABELS = { clear: "Clear search", submit: "Search" };
8
+ const SearchIcon = () => /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [
9
+ /* @__PURE__ */ jsx("circle", { cx: "11", cy: "11", r: "8" }),
10
+ /* @__PURE__ */ jsx("path", { d: "m21 21-4.3-4.3" })
11
+ ] });
12
+ const ClearIcon = () => /* @__PURE__ */ jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" }) });
13
+ function SearchBar({
14
+ label,
15
+ value: valueProp,
16
+ defaultValue = "",
17
+ onValueChange,
18
+ onSubmit,
19
+ onClear,
20
+ placeholder,
21
+ size = "md",
22
+ submitButton = false,
23
+ submitVariant = "primary",
24
+ landmark = true,
25
+ inputDescribedBy,
26
+ labels: labelsProp,
27
+ name,
28
+ inputId,
29
+ inputRef,
30
+ autoFocus,
31
+ disabled,
32
+ className,
33
+ ...props
34
+ }) {
35
+ const uixLabels = useUixLabels();
36
+ const labels = { ...DEFAULT_SEARCH_BAR_LABELS, ...uixLabels.searchBar, ...labelsProp };
37
+ const id = useId();
38
+ const fieldId = inputId ?? `${id}-field`;
39
+ const labelId = `${id}-label`;
40
+ const field = useRef(null);
41
+ const [own, setOwn] = useState(defaultValue);
42
+ const value = valueProp ?? own;
43
+ const change = (next) => {
44
+ setOwn(next);
45
+ onValueChange?.(next);
46
+ };
47
+ const clear = () => {
48
+ change("");
49
+ field.current?.focus();
50
+ onClear?.();
51
+ };
52
+ const onKeyDown = (event) => {
53
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
54
+ if (event.key === "Enter") {
55
+ event.preventDefault();
56
+ onSubmit?.(value);
57
+ } else if (event.key === "Escape" && value !== "") {
58
+ event.preventDefault();
59
+ event.stopPropagation();
60
+ clear();
61
+ }
62
+ };
63
+ const setField = useCallback((node) => {
64
+ field.current = node;
65
+ if (typeof inputRef === "function") inputRef(node);
66
+ else if (inputRef) inputRef.current = node;
67
+ }, [inputRef]);
68
+ return /* @__PURE__ */ jsxs(
69
+ "div",
70
+ {
71
+ role: landmark ? "search" : void 0,
72
+ "aria-labelledby": landmark ? labelId : void 0,
73
+ className: cx("uix-searchbar", size === "md" && "uix-searchbar--md", className),
74
+ ...props,
75
+ children: [
76
+ /* @__PURE__ */ jsxs("div", { className: "uix-searchbar__wrap", children: [
77
+ /* @__PURE__ */ jsx("label", { id: labelId, htmlFor: fieldId, className: "uix-visually-hidden", children: label }),
78
+ /* @__PURE__ */ jsx(SearchIcon, {}),
79
+ /* @__PURE__ */ jsx(
80
+ "input",
81
+ {
82
+ ref: setField,
83
+ id: fieldId,
84
+ type: "search",
85
+ name,
86
+ className: "uix-input",
87
+ value,
88
+ placeholder,
89
+ autoComplete: "off",
90
+ enterKeyHint: "search",
91
+ "aria-describedby": inputDescribedBy,
92
+ autoFocus,
93
+ disabled,
94
+ onChange: (event) => change(event.currentTarget.value),
95
+ onKeyDown
96
+ }
97
+ ),
98
+ value !== "" && !disabled && /* @__PURE__ */ jsx(
99
+ "button",
100
+ {
101
+ type: "button",
102
+ className: "uix-searchbar__clear",
103
+ "aria-label": labels.clear,
104
+ onMouseDown: (event) => event.preventDefault(),
105
+ onClick: clear,
106
+ children: /* @__PURE__ */ jsx(ClearIcon, {})
107
+ }
108
+ )
109
+ ] }),
110
+ submitButton && /* @__PURE__ */ jsx(Button, { type: "button", variant: submitVariant, size: size === "lg" ? "lg" : "md", disabled, onClick: () => onSubmit?.(value), children: labels.submit })
111
+ ]
112
+ }
113
+ );
114
+ }
115
+ export {
116
+ SearchBar
117
+ };
@@ -3,7 +3,7 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { Children, Fragment as Fragment2, forwardRef, isValidElement, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
4
4
  import { cx } from "../cx.js";
5
5
  import { useUixLabels } from "../labels-context.js";
6
- import { useAnchoredPosition } from "../hooks/useAnchoredPosition.js";
6
+ import { useAnchoredPosition, zoomOf } from "../hooks/useAnchoredPosition.js";
7
7
  import { Drawer } from "./Drawer.js";
8
8
  import {
9
9
  buildSelectModel,
@@ -537,9 +537,10 @@ const Select = forwardRef(function Select2(props, forwardedRef) {
537
537
  }
538
538
  };
539
539
  if (showing) {
540
+ const zoom = zoomOf(trigger);
540
541
  const r = trigger.getBoundingClientRect();
541
- const room = Math.max(r.top, window.innerHeight - r.bottom) - OFFSET - EDGE;
542
- popup.style.minWidth = `${Math.round(r.width)}px`;
542
+ const room = Math.max(r.top, window.innerHeight - r.bottom) / zoom - OFFSET - EDGE;
543
+ popup.style.minWidth = `${Math.round(r.width / zoom)}px`;
543
544
  popup.style.maxHeight = `${Math.max(96, Math.min(MAX_HEIGHT, Math.floor(room)))}px`;
544
545
  try {
545
546
  if (!isOpen()) popup.showPopover();
@@ -581,7 +582,7 @@ const Select = forwardRef(function Select2(props, forwardedRef) {
581
582
  doc.addEventListener("pointerdown", onDown, true);
582
583
  return () => doc.removeEventListener("pointerdown", onDown, true);
583
584
  }, [showing]);
584
- useEffect(() => {
585
+ useIsomorphicLayoutEffect(() => {
585
586
  if (!open) return;
586
587
  if (active >= entries.length || active < 0 && entries.length) setActive(entries.length ? selectedIndexIn(entries) >= 0 ? selectedIndexIn(entries) : firstEnabled(entries) : -1);
587
588
  }, [open, entries.length, query]);
@@ -0,0 +1,29 @@
1
+ import * as react from 'react';
2
+ import { KeyShortcut } from './Kbd.js';
3
+
4
+ interface ShortcutHintProps {
5
+ keys: KeyShortcut;
6
+ /** Text before the keys, e.g. an icon button's name. */
7
+ label?: string;
8
+ /** Set the button's `aria-keyshortcuts`. `false` when the button already has its own (a prop, or a Tooltip's). */
9
+ ownsAria: boolean;
10
+ }
11
+ /**
12
+ * The hover / keyboard-focus hint of a `Button` with `shortcut` (HAR-2031). In the button it
13
+ * leaves only an empty, hidden marker, so the button's text, name, layout and selectors are
14
+ * unchanged (no wrapper, no sibling). The bubble itself is portalled after mount into the
15
+ * nearest `<dialog>` (outside one a modal dialog makes everything inert) or `<body>`, as a
16
+ * top-layer popover, `aria-hidden`: assistive technology gets the key from the button's
17
+ * `aria-keyshortcuts`, which this sets for the viewer's platform (Mod is Meta on Apple, Control
18
+ * elsewhere) unless the button already has its own.
19
+ *
20
+ * It opens on hover and on keyboard focus (`:focus-visible`), so focus a menu or dialog sends back
21
+ * after a mouse choice does not open it. It closes on leave, blur, Escape and when the button is
22
+ * disabled, and stays while the pointer is on it (WCAG 1.4.13). Inside a `Tooltip` it steps aside:
23
+ * the Tooltip shows the keys in its own bubble.
24
+ *
25
+ * It is its own client component so that `Button`, which has no hooks, still renders on a server.
26
+ */
27
+ declare function ShortcutHint({ keys, label, ownsAria }: ShortcutHintProps): react.JSX.Element;
28
+
29
+ export { ShortcutHint, type ShortcutHintProps };