@parallelworks/ui 0.78.0 → 0.80.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 (37) hide show
  1. package/README.md +12 -1
  2. package/dist/access/AccessDrawer.d.ts +22 -1
  3. package/dist/access/AccessDrawer.js +33 -9
  4. package/dist/access/index.d.ts +1 -1
  5. package/dist/access/strings.d.ts +7 -1
  6. package/dist/access/strings.js +7 -1
  7. package/dist/ai/components/ChatMessageList.js +21 -6
  8. package/dist/components/Combobox.js +56 -32
  9. package/dist/components/ConfirmModal.d.ts +5 -3
  10. package/dist/components/ConfirmModal.js +22 -3
  11. package/dist/components/ContextMenu.d.ts +8 -0
  12. package/dist/components/ContextMenu.js +42 -6
  13. package/dist/components/CreateModal.d.ts +5 -1
  14. package/dist/components/CreateModal.js +2 -2
  15. package/dist/components/Disclosure.d.ts +6 -2
  16. package/dist/components/Disclosure.js +24 -2
  17. package/dist/components/MenuEntries.d.ts +3 -2
  18. package/dist/components/MenuEntries.js +1 -1
  19. package/dist/components/Popover.d.ts +4 -1
  20. package/dist/components/Popover.js +3 -3
  21. package/dist/components/Provider.d.ts +32 -2
  22. package/dist/components/Provider.js +16 -3
  23. package/dist/components/segmented.js +1 -1
  24. package/dist/graph/InputsEditor.js +3 -2
  25. package/dist/graph/ProblemPageLink.d.ts +9 -0
  26. package/dist/graph/ProblemPageLink.js +21 -0
  27. package/dist/graph/ProblemsButton.js +23 -13
  28. package/dist/graph/RunWarningsList.js +20 -11
  29. package/dist/graph/editorApi.d.ts +10 -0
  30. package/dist/graph/editorRows.js +3 -1
  31. package/dist/graph/editorStrings.d.ts +1 -0
  32. package/dist/graph/editorStrings.js +1 -0
  33. package/dist/graph/runWarnings.d.ts +12 -1
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.js +3 -3
  36. package/dist/styles.css +1 -1
  37. package/package.json +2 -2
package/README.md CHANGED
@@ -59,6 +59,15 @@ even for a one-word name. The host sets the tints with `--avatar-person`,
59
59
  edge `--avatar-agent-ring`. An agent's avatar (`kind="agent"`) is a rounded
60
60
  square under either coloring, so its shape and not only its color sets it apart.
61
61
 
62
+ A run's warnings and the workflow editor's problems may carry a problem `code`, `params` and
63
+ `type`, as a server's problem details do. Bind `data.useProblemMessage` to `useCodeMessage` from
64
+ `@parallelworks/problem/react`, and they read in the app's language: the message at
65
+ `apiErrors.<code>` in the app's use-intl catalog, or the shared one. It gets each one's `type` too:
66
+ for a type that names an application, such as `/problems/workflow/undefined_input`, the message at
67
+ `apiErrors.workflow.undefined_input` comes first. The app's own codes can stay at the top level of
68
+ `apiErrors`; another registry's go under its name, so a code both use keeps each one's message and
69
+ params. A code with no message, or no `useProblemMessage`, shows the `message` the server sent.
70
+
62
71
  Menus, selects, pickers, popovers, tooltips and hover cards open over the page,
63
72
  or over the fullscreen element while there is one. Opened inside a `<dialog>`
64
73
  shown with `showModal()`, they open inside that dialog instead: the browser
@@ -806,7 +815,9 @@ each linking to its job or step: the link opens the subworkflows, matrix and job
806
815
  it and what it is in into view below the graph's view bar, readable, and moves focus to it. Each job and step a warning is about, and each one it sits
807
816
  inside, carries a mark that lists the warnings on hover, so one skipped without a log is still
808
817
  flagged. A job name a matrix ran as several jobs marks each of them. `dag.warningCount` words the
809
- count.
818
+ count. A warning with a `code` reads as the host's `useProblemMessage` words it, and one whose `type`
819
+ names a page, such as `/problems/workflow/undefined_input`, links to that page in a new tab
820
+ (`dag.warningAbout`); the editor's problems list does the same (`problemAbout`).
810
821
 
811
822
  The editor checks YAML against the schemas a host registers with `configureEditorYaml` from
812
823
  `@parallelworks/ui/editor`. The job, step, input and settings dialogs edit their YAML at their own
@@ -59,6 +59,24 @@ export interface AccessEditorProps {
59
59
  /** Each person and group holds one role here. Their row picks it from a list, a change
60
60
  * swaps the old role for the new one in a single change, and the grant form picks one. */
61
61
  singleRole?: boolean | undefined;
62
+ /** Why a save failed, in the reader's words, under "Couldn’t grant…": a fixed sentence, or
63
+ * one made from what `onChange` rejected with, such as a localized message for its error
64
+ * code. Without it, a generic sentence; the error's own message never shows, as a
65
+ * server's words aren't the reader's. */
66
+ errorMessage?: string | ((error: unknown) => string | undefined) | undefined;
67
+ /** Access here is set somewhere else, such as on what the resource is part of: the list
68
+ * shows, read-only, under a note that says where, with a link there. */
69
+ managedElsewhere?: AccessManagedElsewhere | undefined;
70
+ }
71
+ export interface AccessManagedElsewhere {
72
+ /** Where access is set and why: "This team follows the workspace’s access." Without it, a
73
+ * generic sentence. */
74
+ reason?: ReactNode;
75
+ /** Where to change it, through the host's router. */
76
+ href?: string | undefined;
77
+ /** The link's text, which says where it goes: "Workspace access". Without it, "Manage
78
+ * access". */
79
+ linkLabel?: string | undefined;
62
80
  }
63
81
  export interface AccessDrawerProps extends AccessEditorProps {
64
82
  open: boolean;
@@ -69,6 +87,9 @@ export interface AccessDrawerProps extends AccessEditorProps {
69
87
  export interface AccessPanelProps extends AccessEditorProps {
70
88
  /** On the panel's frame. */
71
89
  className?: string | undefined;
90
+ /** Under the list, inside the frame and ruled off from it: a note or actions about access
91
+ * as a whole. The grant form takes its place while it shows. */
92
+ footer?: ReactNode;
72
93
  }
73
94
  /** Who has access to one resource, and the place to change it: roles granted to people and
74
95
  * groups. Overlapping roles are allowed and only redundant, which the drawer points out; taking
@@ -78,7 +99,7 @@ export interface AccessPanelProps extends AccessEditorProps {
78
99
  export declare function AccessDrawer({ open, onClose, width, ...props }: AccessDrawerProps): import("react").JSX.Element;
79
100
  /** The access editor inline on a page, such as a settings section, rather than in a drawer: the
80
101
  * list in a frame, which the grant form takes over with a back arrow in its own header. */
81
- export declare function AccessPanel({ className, ...props }: AccessPanelProps): import("react").JSX.Element;
102
+ export declare function AccessPanel({ className, footer, ...props }: AccessPanelProps): import("react").JSX.Element;
82
103
  type Match = (query: string, ...texts: (string | undefined)[]) => boolean;
83
104
  export declare function filterAccess(model: AccessModel, arrangement: AccessArrangement, query: string, match: Match, t: AccessStrings): {
84
105
  shown: Map<string, readonly string[]>;
@@ -9,7 +9,7 @@ import { LoadingRegion, Skeleton } from "../components/Skeleton.js";
9
9
  import { Button } from "../components/Button.js";
10
10
  import { IconButton } from "../components/IconButton.js";
11
11
  import { Callout } from "../components/Callout.js";
12
- import "../components/dismiss.js";
12
+ import { dialogFooterClassName } from "../components/dismiss.js";
13
13
  import { Drawer } from "../components/Drawer.js";
14
14
  import { Select } from "../components/Select.js";
15
15
  import { SearchInput } from "../components/SearchInput.js";
@@ -28,7 +28,7 @@ var EMPTY_SELECTION = {
28
28
  };
29
29
  /** The list and the grant form, and the saving, failing and undoing they share, for the drawer
30
30
  * and the inline panel to lay out. */
31
- function useAccessEditor({ resourceName, roles, principals, grants, owner, inherited, permissions, onChange, loading = false, readOnly = false, readOnlyReason, describeGroup, searchPrincipals, groupCount, peopleCount, recentPrincipalIds, singleRole = false, defaultArrangement = singleRole ? "principal" : "role", visibility, rules }, inline) {
31
+ function useAccessEditor({ resourceName, roles, principals, grants, owner, inherited, permissions, onChange, loading = false, readOnly = false, readOnlyReason, describeGroup, searchPrincipals, groupCount, peopleCount, recentPrincipalIds, singleRole = false, defaultArrangement = singleRole ? "principal" : "role", visibility, rules, errorMessage, managedElsewhere }, inline) {
32
32
  const t = useAccessStrings();
33
33
  const uid = useId();
34
34
  const openMenu = useOpenMenu();
@@ -93,7 +93,8 @@ function useAccessEditor({ resourceName, roles, principals, grants, owner, inher
93
93
  visibilityChange.current = visibility?.onChange;
94
94
  const match = useQueryMatch();
95
95
  const filtered = useFiltered(model, arrangement, query.trim(), match, t);
96
- const editable = !readOnly && onChange !== void 0;
96
+ const editable = !readOnly && !managedElsewhere && onChange !== void 0;
97
+ const messageOf = (error) => (typeof errorMessage === "function" ? errorMessage(error) : errorMessage) || t.failedReason;
97
98
  const sectionId = (id) => `${uid}${arrangement}${id}`;
98
99
  const names = (changed) => {
99
100
  const all = [...changed.grant, ...changed.revoke];
@@ -344,7 +345,28 @@ function useAccessEditor({ resourceName, roles, principals, grants, owner, inher
344
345
  const list = /* @__PURE__ */ jsxs("div", {
345
346
  className: "@container/access flex min-h-0 flex-1 flex-col",
346
347
  children: [
347
- readOnly && /* @__PURE__ */ jsx("div", {
348
+ managedElsewhere ? /* @__PURE__ */ jsx("div", {
349
+ className: cx("px-4 pt-3", empty && !visibility && "pb-3"),
350
+ children: /* @__PURE__ */ jsx(Callout, {
351
+ tone: "info",
352
+ icon: /* @__PURE__ */ jsx(LockIcon, {}),
353
+ children: /* @__PURE__ */ jsxs("div", {
354
+ className: "flex flex-col items-start gap-2 @min-[30rem]/access:flex-row @min-[30rem]/access:gap-3",
355
+ children: [/* @__PURE__ */ jsx("div", {
356
+ className: "min-w-0 flex-1",
357
+ children: managedElsewhere.reason ?? t.managedElsewhere
358
+ }), managedElsewhere.href !== void 0 && /* @__PURE__ */ jsx("div", {
359
+ className: "flex shrink-0 items-center @min-[30rem]/access:h-5",
360
+ children: /* @__PURE__ */ jsx(Button, {
361
+ size: "sm",
362
+ variant: "secondary",
363
+ href: managedElsewhere.href,
364
+ children: managedElsewhere.linkLabel ?? t.manageAccess
365
+ })
366
+ })]
367
+ })
368
+ })
369
+ }) : readOnly && /* @__PURE__ */ jsx("div", {
348
370
  className: "px-4 pt-3",
349
371
  children: /* @__PURE__ */ jsxs(Callout, {
350
372
  tone: "info",
@@ -397,7 +419,7 @@ function useAccessEditor({ resourceName, roles, principals, grants, owner, inher
397
419
  children: loading ? /* @__PURE__ */ jsx(LoadingRegion, {
398
420
  label: t.loading,
399
421
  children: /* @__PURE__ */ jsx(RowsSkeleton, {})
400
- }) : empty ? /* @__PURE__ */ jsxs(Fragment$1, { children: [ruleRows, /* @__PURE__ */ jsx(EmptyState, {
422
+ }) : empty && managedElsewhere ? ruleRows : empty ? /* @__PURE__ */ jsxs(Fragment$1, { children: [ruleRows, /* @__PURE__ */ jsx(EmptyState, {
401
423
  variant: "bare",
402
424
  icon: /* @__PURE__ */ jsx(UsersIcon, {}),
403
425
  title: t.emptyTitle,
@@ -513,7 +535,7 @@ function AccessDrawer({ open, onClose, width = 560, ...props }) {
513
535
  }
514
536
  /** The access editor inline on a page, such as a settings section, rather than in a drawer: the
515
537
  * list in a frame, which the grant form takes over with a back arrow in its own header. */
516
- function AccessPanel({ className, ...props }) {
538
+ function AccessPanel({ className, footer, ...props }) {
517
539
  const t = useAccessStrings();
518
540
  const editor = useAccessEditor(props, true);
519
541
  return /* @__PURE__ */ jsxs("section", {
@@ -542,14 +564,16 @@ function AccessPanel({ className, ...props }) {
542
564
  })]
543
565
  }),
544
566
  editor.body,
545
- editor.footer && /* @__PURE__ */ jsx("div", {
546
- className: "flex shrink-0 items-center justify-end gap-2 border-t border-(--theme-border) px-4 py-3",
567
+ editor.footer ? /* @__PURE__ */ jsx("div", {
568
+ className: dialogFooterClassName,
547
569
  children: editor.footer
570
+ }) : footer !== void 0 && footer !== null && footer !== false && /* @__PURE__ */ jsx("div", {
571
+ className: "border-t border-(--theme-border) px-4 py-3 text-sm text-(--theme-muted-text-color)",
572
+ children: footer
548
573
  })
549
574
  ]
550
575
  });
551
576
  }
552
- var messageOf = (error) => error instanceof Error && error.message ? error.message : void 0;
553
577
  var anchorOf = (failure, arrangement) => arrangement === "role" ? failure.roleId ?? "" : failure.principalId;
554
578
  /** The one of `elements` that is laid out, such as the one of two buttons a width shows, or the
555
579
  * first where nothing is (as in tests). */
@@ -1,3 +1,3 @@
1
- export { type AccessArrangement, AccessDrawer, type AccessDrawerProps, type AccessEditorProps, AccessPanel, type AccessPanelProps, } from './AccessDrawer.js';
1
+ export { type AccessArrangement, AccessDrawer, type AccessDrawerProps, type AccessEditorProps, type AccessManagedElsewhere, AccessPanel, type AccessPanelProps, } from './AccessDrawer.js';
2
2
  export { ACCESS_STRINGS, type AccessStrings, type AccessText, useAccessStrings, } from './strings.js';
3
3
  export type { AccessChange, AccessGrant, AccessGroupDetails, AccessInheritedGrant, AccessMember, AccessPermission, AccessPrincipal, AccessRole, AccessRule, AccessVisibility, } from './types.js';
@@ -66,7 +66,9 @@ export declare const ACCESS_STRINGS: {
66
66
  removeFailed: (principal: string, roles: readonly string[]) => string;
67
67
  grantFailed: (principals: readonly string[], roles: readonly string[]) => string;
68
68
  undoFailed: string;
69
- /** Under a failed save; `reason` is the host's error message, when it gave one. */
69
+ /** Why a save failed, when the host gives no `errorMessage`. */
70
+ failedReason: string;
71
+ /** Under a failed save; `reason` is the host's `errorMessage`, or `failedReason`. */
70
72
  nothingChanged: (reason: string | undefined) => string;
71
73
  /** The toast after a grant. */
72
74
  granted: (principals: readonly string[], roles: readonly string[]) => string;
@@ -74,6 +76,10 @@ export declare const ACCESS_STRINGS: {
74
76
  removed: (principals: readonly string[], roles: readonly string[]) => string;
75
77
  undone: string;
76
78
  readOnly: string;
79
+ /** A note atop access set somewhere else, when the host gives no reason. */
80
+ managedElsewhere: string;
81
+ /** The note's link to where access is set, when the host doesn't name it. */
82
+ manageAccess: string;
77
83
  /** After `readOnly` when the host gives no reason. */
78
84
  readOnlyReason: string;
79
85
  /** Names the switch between open to everyone and only those listed. */
@@ -91,7 +91,9 @@ var ACCESS_STRINGS = {
91
91
  removeFailed: (principal, roles) => `Couldn’t remove ${principal} from ${and(roles)}`,
92
92
  grantFailed: (principals, roles) => `Couldn’t grant ${and(roles)} to ${and(principals)}`,
93
93
  undoFailed: "Couldn’t undo the change",
94
- /** Under a failed save; `reason` is the host's error message, when it gave one. */
94
+ /** Why a save failed, when the host gives no `errorMessage`. */
95
+ failedReason: "Something went wrong.",
96
+ /** Under a failed save; `reason` is the host's `errorMessage`, or `failedReason`. */
95
97
  nothingChanged: (reason) => reason ? `${reason.replace(/\.?$/, ".")} Nothing changed.` : "Nothing changed.",
96
98
  /** The toast after a grant. */
97
99
  granted: (principals, roles) => `Granted ${and(roles)} to ${and(principals)}`,
@@ -99,6 +101,10 @@ var ACCESS_STRINGS = {
99
101
  removed: (principals, roles) => `Removed ${and(principals)} from ${and(roles)}`,
100
102
  undone: "Change undone",
101
103
  readOnly: "View only.",
104
+ /** A note atop access set somewhere else, when the host gives no reason. */
105
+ managedElsewhere: "Access here is set somewhere else, so it can’t be changed here.",
106
+ /** The note's link to where access is set, when the host doesn't name it. */
107
+ manageAccess: "Manage access",
102
108
  /** After `readOnly` when the host gives no reason. */
103
109
  readOnlyReason: "Only people who manage access can make changes.",
104
110
  /** Names the switch between open to everyone and only those listed. */
@@ -45,8 +45,8 @@ function ChatMessageList({ messages, allMessages, streamingMessage: streamingMes
45
45
  const open = adapter.attachments?.onOpen;
46
46
  return open ? open.bind(adapter.attachments) : void 0;
47
47
  }, [adapter]);
48
- const bottomRef = useRef(null);
49
48
  const containerRef = useRef(null);
49
+ const contentRef = useRef(null);
50
50
  const reasoningScrollRef = useRef(null);
51
51
  const messageCount = messages.length;
52
52
  const streamingText = streamingMessage?.content;
@@ -145,8 +145,25 @@ function ChatMessageList({ messages, allMessages, streamingMessage: streamingMes
145
145
  ]);
146
146
  useEffect(() => {
147
147
  if (!containerRef.current || !isNearBottomRef.current || isStreaming || !hadMessagesRef.current) return;
148
- bottomRef.current?.scrollIntoView({ behavior: prefersReducedMotion() ? "auto" : "smooth" });
148
+ const el = containerRef.current;
149
+ el.scrollTo({
150
+ top: el.scrollHeight,
151
+ behavior: prefersReducedMotion() ? "auto" : "smooth"
152
+ });
149
153
  }, [messageCount, isStreaming]);
154
+ useEffect(() => {
155
+ const content = contentRef.current;
156
+ if (!content || typeof ResizeObserver === "undefined") return;
157
+ const observer = new ResizeObserver(() => {
158
+ const el = containerRef.current;
159
+ if (el && isNearBottomRef.current && !hasQueuedMessages) {
160
+ el.scrollTop = el.scrollHeight;
161
+ lastScrollTopRef.current = el.scrollTop;
162
+ }
163
+ });
164
+ observer.observe(content);
165
+ return () => observer.disconnect();
166
+ }, [hasContent, hasQueuedMessages]);
150
167
  if (messages.length === 0 && !streamingMessage && !hasQueuedMessages) return /* @__PURE__ */ jsxs("div", {
151
168
  className: "chat-thread flex-1 overflow-y-auto flex flex-col bg-(--theme-panel-bg)",
152
169
  children: [
@@ -170,6 +187,7 @@ function ChatMessageList({ messages, allMessages, streamingMessage: streamingMes
170
187
  className: "chat-thread flex-1 overflow-y-auto theme-scrollbar flex flex-col bg-(--theme-panel-bg)",
171
188
  children: [
172
189
  /* @__PURE__ */ jsxs("div", {
190
+ ref: contentRef,
173
191
  className: "w-full max-w-[var(--chat-column,50rem)] mx-auto pt-4 pb-6 flex-1 px-4",
174
192
  children: [
175
193
  /* @__PURE__ */ jsx(MessageRows, {
@@ -283,10 +301,7 @@ function ChatMessageList({ messages, allMessages, streamingMessage: streamingMes
283
301
  }, msg.id);
284
302
  }),
285
303
  footer,
286
- /* @__PURE__ */ jsx("div", {
287
- ref: bottomRef,
288
- className: "h-8"
289
- })
304
+ /* @__PURE__ */ jsx("div", { className: "h-8" })
290
305
  ]
291
306
  }),
292
307
  !pinned && /* @__PURE__ */ jsx("div", {
@@ -91,6 +91,11 @@ function Combobox$1(props) {
91
91
  const list = useRef(null);
92
92
  const uid = useId();
93
93
  const [inputValue, setInputValue] = useState(null);
94
+ /** Empties the search, and tells a host that mirrors it. */
95
+ const clearQuery = (to = "") => {
96
+ if (inputValue) onQueryChange?.("");
97
+ setInputValue(to);
98
+ };
94
99
  const [highlighted, setHighlighted] = useState(void 0);
95
100
  const [startsOpen] = useState(open === void 0 && defaultOpen === true);
96
101
  const [ownOpen, setOwnOpen] = useState(false);
@@ -152,7 +157,7 @@ function Combobox$1(props) {
152
157
  }
153
158
  if (next.length === chosen.length) return false;
154
159
  setValues(next);
155
- setInputValue("");
160
+ clearQuery();
156
161
  return true;
157
162
  };
158
163
  const separators = props.multiple && props.creatable ? props.separators ?? [] : [];
@@ -173,11 +178,16 @@ function Combobox$1(props) {
173
178
  };
174
179
  const freeEntry = creatable && entries.flat.length === 0;
175
180
  const [first] = entries.items;
176
- const listItems = !created ? entries.items : first !== void 0 && "items" in first ? [{
181
+ const createLast = created?.action === true;
182
+ const listItems = !created ? entries.items : first !== void 0 && "items" in first ? createLast ? [...entries.items, {
183
+ label: "",
184
+ items: [created],
185
+ order: entries.items.length
186
+ }] : [{
177
187
  label: "",
178
188
  items: [created],
179
189
  order: -1
180
- }, ...entries.items] : [created, ...entries.items];
190
+ }, ...entries.items] : createLast ? [...entries.items, created] : [created, ...entries.items];
181
191
  const selectedIcon = selected?.icon && (inputValue === null || inputValue === selected.label) ? selected.icon : null;
182
192
  const input = /* @__PURE__ */ jsx(Combobox.Input, {
183
193
  id: trigger ? void 0 : id,
@@ -227,34 +237,48 @@ function Combobox$1(props) {
227
237
  })]
228
238
  })]
229
239
  }, rowProps.key);
230
- const parts = trigger ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Tooltip, {
231
- content: triggerTooltip,
232
- children: /* @__PURE__ */ jsx(Combobox.Trigger, {
233
- id,
234
- "aria-label": ariaLabel || void 0,
235
- "aria-labelledby": ariaLabelledBy,
236
- "aria-describedby": ariaDescribedBy,
237
- "aria-invalid": ariaInvalid || void 0,
238
- "aria-required": ariaRequired || required || void 0,
239
- render: trigger
240
- })
241
- }), /* @__PURE__ */ jsx(ListPopup, {
242
- listRef: list,
243
- empty: t.noOptionsFound,
244
- statusGroups: entries.statusGroups,
245
- query: inputValue ?? "",
246
- header: /* @__PURE__ */ jsx("div", {
247
- className: "border-b border-(--theme-border) p-1.5",
248
- children: input
240
+ const labelId = `${uid}-label`;
241
+ const valueId = `${uid}-value`;
242
+ const named = Boolean(ariaLabel || ariaLabelledBy);
243
+ const parts = trigger ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
244
+ named && /* @__PURE__ */ jsxs(Fragment$1, { children: [ariaLabel && !ariaLabelledBy && /* @__PURE__ */ jsx("span", {
245
+ id: labelId,
246
+ hidden: true,
247
+ children: ariaLabel
248
+ }), /* @__PURE__ */ jsx("span", {
249
+ id: valueId,
250
+ hidden: true,
251
+ children: /* @__PURE__ */ jsx(Combobox.Value, { children: (value) => Array.isArray(value) ? value.map(entryLabel).join(", ") : entryLabel(value) })
252
+ })] }),
253
+ /* @__PURE__ */ jsx(Tooltip, {
254
+ content: triggerTooltip,
255
+ children: /* @__PURE__ */ jsx(Combobox.Trigger, {
256
+ id,
257
+ "aria-labelledby": named ? `${ariaLabelledBy || labelId} ${valueId}` : void 0,
258
+ "aria-describedby": ariaDescribedBy,
259
+ "aria-invalid": ariaInvalid || void 0,
260
+ "aria-required": ariaRequired || required || void 0,
261
+ render: trigger
262
+ })
249
263
  }),
250
- className: details ? "w-[min(36rem,var(--available-width))]" : "w-max min-w-[min(max(14rem,var(--anchor-width)),var(--available-width))] max-w-[min(28rem,var(--available-width))]",
251
- sizer: details ? void 0 : /* @__PURE__ */ jsx(ListSizer, { entries: entries.flat }),
252
- align,
253
- loading: loading ? t.searching : void 0,
254
- details: details?.(highlighted?.created ? void 0 : highlighted?.value),
255
- collisionBoundary,
256
- renderItem: itemRow
257
- })] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
264
+ /* @__PURE__ */ jsx(ListPopup, {
265
+ listRef: list,
266
+ empty: t.noOptionsFound,
267
+ statusGroups: entries.statusGroups,
268
+ query: inputValue ?? "",
269
+ header: /* @__PURE__ */ jsx("div", {
270
+ className: "border-b border-(--theme-border) p-1.5",
271
+ children: input
272
+ }),
273
+ className: details ? "w-[min(36rem,var(--available-width))]" : "w-max min-w-[min(max(14rem,var(--anchor-width)),var(--available-width))] max-w-[min(28rem,var(--available-width))]",
274
+ sizer: details ? void 0 : /* @__PURE__ */ jsx(ListSizer, { entries: entries.flat }),
275
+ align,
276
+ loading: loading ? t.searching : void 0,
277
+ details: details?.(highlighted?.created ? void 0 : highlighted?.value),
278
+ collisionBoundary,
279
+ renderItem: itemRow
280
+ })
281
+ ] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
258
282
  /* @__PURE__ */ jsxs(Combobox.InputGroup, {
259
283
  "data-testid": "combobox",
260
284
  className: cx(fieldClassName(size), freeEntry && "pr-2", className),
@@ -324,7 +348,7 @@ function Combobox$1(props) {
324
348
  ...open !== void 0 ? { open } : startsOpen ? { open: ownOpen } : {},
325
349
  onOpenChange: (next) => {
326
350
  setOwnOpen(next);
327
- if (!next && (trigger || !props.multiple)) setInputValue(null);
351
+ if (!next && (trigger || !props.multiple)) clearQuery(null);
328
352
  onOpenChange?.(next);
329
353
  },
330
354
  children: parts
@@ -343,7 +367,7 @@ function Combobox$1(props) {
343
367
  const action = next.find((entry) => entry.action);
344
368
  if (action) {
345
369
  details.cancel();
346
- setInputValue("");
370
+ clearQuery();
347
371
  onCreate?.(String(action.value));
348
372
  return;
349
373
  }
@@ -23,9 +23,11 @@ export interface ConfirmModalProps {
23
23
  cancelLabel?: string | undefined;
24
24
  /** When false, the dialog stays open after a successful confirm. */
25
25
  closeOnConfirm?: boolean | undefined;
26
- /** Which button has focus as it opens. Cancel for a destructive dialog, so a stray Enter
27
- * destroys nothing; the confirm button otherwise, so the keyboard answers at once. */
28
- initialFocus?: 'cancel' | 'confirm' | undefined;
26
+ /** What has focus as it opens. With a field in the body, such as a name to type, the first
27
+ * of them (`first-control`), so the reader types at once. Without one, Cancel for a
28
+ * destructive dialog, so a stray Enter destroys nothing, and the confirm button otherwise,
29
+ * so the keyboard answers at once. */
30
+ initialFocus?: 'cancel' | 'confirm' | 'first-control' | undefined;
29
31
  /** Work the host runs for it, such as a mutation already under way: the buttons wait, and
30
32
  * the dialog can't be dismissed, as while its own handler runs. */
31
33
  pending?: boolean | undefined;
@@ -5,14 +5,25 @@ import { Modal } from "./Modal.js";
5
5
  import { useCallback, useEffect, useRef, useState } from "react";
6
6
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
7
  //#region src/components/ConfirmModal.tsx
8
+ /** What the reader types or picks in, which takes focus ahead of the buttons. A checkbox or
9
+ * radio doesn't: a destructive dialog with one still opens on Cancel. Inputs a picker keeps
10
+ * out of the tab order, for its form value, aren't fields. */
11
+ var FIELDS = [
12
+ "input:not([disabled]):not([type=\"hidden\"]):not([type=\"checkbox\"]):not([type=\"radio\"])",
13
+ "select:not([disabled])",
14
+ "textarea:not([disabled])",
15
+ "[role=\"combobox\"]:not([disabled]):not([aria-disabled=\"true\"])",
16
+ "[contenteditable=\"true\"]"
17
+ ].map((field) => `${field}:not([tabindex="-1"]):not([aria-hidden="true"])`).join(", ");
8
18
  /** Asks before an action: its title the question, its description what follows,
9
19
  * and Cancel beside a confirm button named for the action. */
10
- function ConfirmModal({ open, onClose, title, description, children, confirmLabel, onConfirm, confirmDisabled = false, destructive = false, align = "top", cancelLabel, closeOnConfirm = true, initialFocus = destructive ? "cancel" : "confirm", pending: pendingProp = false, error, size = "md" }) {
20
+ function ConfirmModal({ open, onClose, title, description, children, confirmLabel, onConfirm, confirmDisabled = false, destructive = false, align = "top", cancelLabel, closeOnConfirm = true, initialFocus, pending: pendingProp = false, error, size = "md" }) {
11
21
  const { common } = useStrings();
12
22
  const [running, setRunning] = useState(false);
13
23
  const fired = useRef(false);
14
24
  const cancelButton = useRef(null);
15
25
  const confirmButton = useRef(null);
26
+ const bodyElement = useRef(null);
16
27
  useEffect(() => {
17
28
  if (open) fired.current = false;
18
29
  }, [open]);
@@ -44,7 +55,11 @@ function ConfirmModal({ open, onClose, title, description, children, confirmLabe
44
55
  size,
45
56
  title,
46
57
  description,
47
- initialFocus: initialFocus === "cancel" ? cancelButton : confirmButton,
58
+ initialFocus: () => {
59
+ const button = (initialFocus === "cancel" || initialFocus !== "confirm" && destructive ? cancelButton : confirmButton).current;
60
+ if (initialFocus === "cancel" || initialFocus === "confirm") return button ?? true;
61
+ return bodyElement.current?.querySelector(FIELDS) ?? button ?? true;
62
+ },
48
63
  footer: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
49
64
  ref: cancelButton,
50
65
  variant: "ghost",
@@ -61,7 +76,11 @@ function ConfirmModal({ open, onClose, title, description, children, confirmLabe
61
76
  loading: pending,
62
77
  children: confirmLabel
63
78
  })] }),
64
- children: body
79
+ children: body !== void 0 && /* @__PURE__ */ jsx("div", {
80
+ ref: bodyElement,
81
+ className: "contents",
82
+ children: body
83
+ })
65
84
  });
66
85
  }
67
86
  /** Asks from anywhere, such as a menu row, without keeping a dialog's state by hand:
@@ -10,7 +10,11 @@ export interface OpenMenuOptions {
10
10
  size?: MenuSize | undefined;
11
11
  /** As `MenuPopup`'s: classes on the menu and its submenus, such as a terminal's colors. */
12
12
  themeClassName?: string | undefined;
13
+ /** What else closes it, for a menu over something that moves under it, such as a
14
+ * terminal's: a wheel outside the menu, the window losing focus, or the window resizing. */
15
+ closeOn?: readonly MenuCloseTrigger[] | undefined;
13
16
  }
17
+ export type MenuCloseTrigger = 'wheel' | 'blur' | 'resize';
14
18
  /** Where a menu opens: an element, such as the button that opened it, or a pointer event,
15
19
  * such as a right-click. An event with Shift held is left to the browser, whose own menu
16
20
  * then shows; any other has its default prevented. */
@@ -29,6 +33,7 @@ export type OpenMenu = (at: MenuOpener, items: MenuEntry[], options?: OpenMenuOp
29
33
  */
30
34
  export declare function useContextMenu(): {
31
35
  openMenu: OpenMenu;
36
+ closeMenu: () => void;
32
37
  contextMenu: import("react").JSX.Element | null;
33
38
  openedBy: Element | null;
34
39
  };
@@ -40,5 +45,8 @@ export declare function ContextMenuProvider({ children }: {
40
45
  /** Opens the app's one shared menu: `openMenu(e, items)` from a right-click, or
41
46
  * `openMenu(button, items)` under a button, which a second press closes. */
42
47
  export declare function useOpenMenu(): OpenMenu;
48
+ /** Closes the app's shared menu, if it is open, for a host whose surface moves out from under
49
+ * it in a way `closeOn` doesn't cover, such as a pane that switches. */
50
+ export declare function useCloseMenu(): () => void;
43
51
  /** The element the shared menu is open under, so its button can say it's expanded. */
44
52
  export declare function useMenuOpenedBy(): Element | null;
@@ -3,7 +3,7 @@ import { MenuPopup } from "./Menu.js";
3
3
  import { isDev } from "../dev.js";
4
4
  import { MenuEntries, MenuFilter } from "./MenuEntries.js";
5
5
  import { Menu } from "@base-ui/react/menu";
6
- import { createContext, use, useCallback, useRef, useState } from "react";
6
+ import { createContext, use, useCallback, useEffect, useRef, useState } from "react";
7
7
  import { jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/components/ContextMenu.tsx
9
9
  /**
@@ -44,6 +44,31 @@ function useContextMenu() {
44
44
  setOpen(true);
45
45
  return true;
46
46
  }, [release]);
47
+ /** Closes the menu, if one is open, as Escape would. */
48
+ const closeMenu = useCallback(() => {
49
+ if (current.current) {
50
+ current.current = null;
51
+ setOpen(false);
52
+ }
53
+ }, []);
54
+ const closeOn = open ? opened?.closeOn : void 0;
55
+ useEffect(() => {
56
+ if (!closeOn?.length) return;
57
+ const onWheel = (event) => {
58
+ if (!(event.target instanceof Element && event.target.closest("[role=\"menu\"]"))) closeMenu();
59
+ };
60
+ const listeners = [];
61
+ if (closeOn.includes("wheel")) listeners.push(["wheel", onWheel]);
62
+ if (closeOn.includes("blur")) listeners.push(["blur", closeMenu]);
63
+ if (closeOn.includes("resize")) listeners.push(["resize", closeMenu]);
64
+ for (const [type, listener] of listeners) window.addEventListener(type, listener, {
65
+ capture: true,
66
+ passive: true
67
+ });
68
+ return () => {
69
+ for (const [type, listener] of listeners) window.removeEventListener(type, listener, { capture: true });
70
+ };
71
+ }, [closeOn, closeMenu]);
47
72
  const onOpenChange = useCallback((next, details) => {
48
73
  if (next) return;
49
74
  const element = current.current?.element;
@@ -61,6 +86,7 @@ function useContextMenu() {
61
86
  }, [release]);
62
87
  return {
63
88
  openMenu,
89
+ closeMenu,
64
90
  contextMenu: opened && /* @__PURE__ */ jsx(MenuFilter, {
65
91
  search: opened.search,
66
92
  children: (query) => /* @__PURE__ */ jsx(Menu.Root, {
@@ -85,15 +111,19 @@ function useContextMenu() {
85
111
  }
86
112
  var OpenMenuContext = createContext(null);
87
113
  var OpenedByContext = createContext(null);
114
+ var CloseMenuContext = createContext(null);
88
115
  /** The one menu every row, button and canvas below it opens through `useOpenMenu`, so none
89
116
  * renders a menu of its own. `UIProvider` mounts it; nothing else needs to. */
90
117
  function ContextMenuProvider({ children }) {
91
- const { openMenu, contextMenu, openedBy } = useContextMenu();
118
+ const { openMenu, closeMenu, contextMenu, openedBy } = useContextMenu();
92
119
  return /* @__PURE__ */ jsx(OpenMenuContext, {
93
120
  value: openMenu,
94
- children: /* @__PURE__ */ jsxs(OpenedByContext, {
95
- value: openedBy,
96
- children: [children, contextMenu]
121
+ children: /* @__PURE__ */ jsx(CloseMenuContext, {
122
+ value: closeMenu,
123
+ children: /* @__PURE__ */ jsxs(OpenedByContext, {
124
+ value: openedBy,
125
+ children: [children, contextMenu]
126
+ })
97
127
  })
98
128
  });
99
129
  }
@@ -106,9 +136,15 @@ var warnNoProvider = () => {
106
136
  function useOpenMenu() {
107
137
  return use(OpenMenuContext) ?? warnNoProvider;
108
138
  }
139
+ var noMenu = () => {};
140
+ /** Closes the app's shared menu, if it is open, for a host whose surface moves out from under
141
+ * it in a way `closeOn` doesn't cover, such as a pane that switches. */
142
+ function useCloseMenu() {
143
+ return use(CloseMenuContext) ?? noMenu;
144
+ }
109
145
  /** The element the shared menu is open under, so its button can say it's expanded. */
110
146
  function useMenuOpenedBy() {
111
147
  return use(OpenedByContext);
112
148
  }
113
149
  //#endregion
114
- export { ContextMenuProvider, useContextMenu, useMenuOpenedBy, useOpenMenu };
150
+ export { ContextMenuProvider, useCloseMenu, useContextMenu, useMenuOpenedBy, useOpenMenu };
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import { type ComboboxItems } from './comboboxItems.js';
3
3
  import type { MenuEntry, MenuSearch } from './MenuEntries.js';
4
+ import { type ModalSize } from './Modal.js';
4
5
  interface CreateModalProps {
5
6
  open: boolean;
6
7
  onClose: () => void;
@@ -43,6 +44,9 @@ interface CreateModalProps {
43
44
  footerHint?: ReactNode;
44
45
  /** Why the last submit failed, at the footer's start, read out as it appears. */
45
46
  error?: ReactNode;
47
+ /** Its width, as `Modal`'s: `xl` for a long body such as a rich text editor. It narrows to
48
+ * fit a small window. */
49
+ size?: ModalSize | undefined;
46
50
  /** Resolves true to close the modal, false to keep it open (e.g. on error). `modifier` is
47
51
  * true when submitted with ⌘/Ctrl+Enter, so a host can create and then open what it made. */
48
52
  onSubmit: (name: string, description: string, favorite: boolean, how: {
@@ -50,7 +54,7 @@ interface CreateModalProps {
50
54
  }) => Promise<boolean>;
51
55
  }
52
56
  /** Submits on Enter in the name field or ⌘/Ctrl+Enter from anywhere in the panel. */
53
- export declare function CreateModal({ open, onClose, typeIcon, typeLabel, typeMenu, typeMenuSearch, namePlaceholder, hideName, initialName, showDescription, descriptionPlaceholder, descriptionField, submitLabel, submittingLabel, validateName, sanitizeName, nameHint, children, properties, advanced, submitDisabled, hideSubmit, nameOptional, onNameChange, dirty, showFavorite, footerHint, error, onSubmit, }: CreateModalProps): import("react").JSX.Element;
57
+ export declare function CreateModal({ open, onClose, typeIcon, typeLabel, typeMenu, typeMenuSearch, namePlaceholder, hideName, initialName, showDescription, descriptionPlaceholder, descriptionField, submitLabel, submittingLabel, validateName, sanitizeName, nameHint, children, properties, advanced, submitDisabled, hideSubmit, nameOptional, onNameChange, dirty, showFavorite, footerHint, error, size, onSubmit, }: CreateModalProps): import("react").JSX.Element;
54
58
  /** Compact property pill with an on/off switch. */
55
59
  export declare function CreateModalSwitch({ label, checked, onCheckedChange, tooltip, }: {
56
60
  label: string;
@@ -16,7 +16,7 @@ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
16
16
  import { Toggle } from "@base-ui/react/toggle";
17
17
  //#region src/components/CreateModal.tsx
18
18
  /** Submits on Enter in the name field or ⌘/Ctrl+Enter from anywhere in the panel. */
19
- function CreateModal({ open, onClose, typeIcon, typeLabel, typeMenu, typeMenuSearch, namePlaceholder, hideName = false, initialName, showDescription = true, descriptionPlaceholder, descriptionField, submitLabel, submittingLabel, validateName, sanitizeName, nameHint, children, properties, advanced, submitDisabled = false, hideSubmit = false, nameOptional = false, onNameChange, dirty = false, showFavorite = false, footerHint, error, onSubmit }) {
19
+ function CreateModal({ open, onClose, typeIcon, typeLabel, typeMenu, typeMenuSearch, namePlaceholder, hideName = false, initialName, showDescription = true, descriptionPlaceholder, descriptionField, submitLabel, submittingLabel, validateName, sanitizeName, nameHint, children, properties, advanced, submitDisabled = false, hideSubmit = false, nameOptional = false, onNameChange, dirty = false, showFavorite = false, footerHint, error, size = "lg", onSubmit }) {
20
20
  const t = useStrings().modal;
21
21
  const { common } = useStrings();
22
22
  const submitText = submitLabel ?? common.create;
@@ -75,7 +75,7 @@ function CreateModal({ open, onClose, typeIcon, typeLabel, typeMenu, typeMenuSea
75
75
  open,
76
76
  onClose,
77
77
  "aria-label": `${submitText}: ${typeLabel}`,
78
- size: "lg",
78
+ size,
79
79
  dismissible: !isDirty,
80
80
  initialFocus: () => nameRef.current ?? true,
81
81
  onKeyDown: (e) => {