@parallelworks/ui 0.59.0 → 0.61.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 CHANGED
@@ -74,6 +74,7 @@ Each need has one component. Reach for it before styling markup by hand.
74
74
  | Free text with suggestions, or tags | `Autocomplete`; `Combobox` with `multiple creatable` |
75
75
  | A range | `Slider` |
76
76
  | A page's breadcrumbs, title and commands | `PageHeader`, shown by the shell's `PageHeaderBar` (see [Page header](#page-header)) |
77
+ | A trail anywhere else, such as a folder's path in a panel | `Breadcrumbs`, which folds as the header's trail does |
77
78
  | An action in the page header | `PageHeaderAction`; `PageHeaderAddButton` for "+ Add" or "+ Create"; `PageHeaderMenu` for the "…" menu |
78
79
  | A titled part of a page or form | `Section` |
79
80
  | More detail under a line | `Disclosure` |
@@ -173,7 +174,12 @@ A page declares its header in one place, and the shell shows it:
173
174
  trail is a list in a `nav` landmark named by the strings' `breadcrumb`. A
174
175
  trail too wide for its bar folds its ancestors into a "…" menu of links
175
176
  (named by `showFullPath`), keeping the parent when it fits beside the page's
176
- name; the name truncates last, with the full name on hover.
177
+ name; the name truncates last, with the full name on hover. The bar draws it
178
+ with `Breadcrumbs`, which renders the same trail anywhere else: in a flex
179
+ row it takes the room the row's other items leave it, and elsewhere its
180
+ container's width. `current="location"` marks a place within the page, such
181
+ as the open folder, and `heading` makes the last crumb the page's `h1`, as
182
+ the bar does.
177
183
  - **`titleActions`** sit beside the title, for actions on the page's subject.
178
184
  **`actions`** sit at the end of the bar, for the page's commands: a detail
179
185
  page's Start, Stop or Terminal go here, not in a row of buttons below it.
@@ -479,6 +485,7 @@ where it opens elsewhere, and renders it through `AppLink`:
479
485
  | `Button`, `IconButton`, `Card` | `href` |
480
486
  | Menu rows, `RowAction`s (a list row's menu and pinned buttons), `ToolbarAction`s | `href` on the action, with `target: '_blank'` for a new tab |
481
487
  | `PageHeaderAction`, `PageHeaderAddButton`, `PageHeader` breadcrumbs | `href`; `external` opens a new tab |
488
+ | `Breadcrumbs` | `href` on the crumb; `onSelect` only for a place with no URL, as the explorer's trail without `pathHref` |
482
489
  | `Tabs` that are pages, `Pagination` | `href` on the item, `pageHref` |
483
490
  | A list row | `ListRow` `href` with a `NameCell` of the same `href`: the name's link covers the row |
484
491
  | A toast's action | `action: { label, href }` |
@@ -2,10 +2,10 @@ import { AddIcon, LockIcon, UsersIcon } from "../icons.js";
2
2
  import { toast } from "../components/Toast.js";
3
3
  import { EmptyState } from "../components/EmptyState.js";
4
4
  import { useQueryMatch } from "../components/search.js";
5
+ import { LoadingRegion, Skeleton } from "../components/Skeleton.js";
5
6
  import { Button } from "../components/Button.js";
6
7
  import { Callout } from "../components/Callout.js";
7
8
  import { Drawer } from "../components/Drawer.js";
8
- import { LoadingRegion, Skeleton } from "../components/Skeleton.js";
9
9
  import { SearchInput } from "../components/SearchInput.js";
10
10
  import { SegmentedControl } from "../components/SegmentedControl.js";
11
11
  import { buildModel, invert, key, principalsWithAccess, warnAboutMisuse } from "./model.js";
@@ -3,9 +3,9 @@ import { OrganizationIcon, RefreshIcon, UsersIcon } from "../icons.js";
3
3
  import { useQueryMatch } from "../components/search.js";
4
4
  import { categoryClasses } from "../components/category.js";
5
5
  import { Avatar } from "../components/Avatar.js";
6
+ import { Skeleton } from "../components/Skeleton.js";
6
7
  import { Callout } from "../components/Callout.js";
7
8
  import { Combobox } from "../components/Combobox.js";
8
- import { Skeleton } from "../components/Skeleton.js";
9
9
  import { useRelativeTime } from "../components/relativeTime.js";
10
10
  import { StatusBadge } from "../components/StatusBadge.js";
11
11
  import { covers, isOwner } from "./model.js";
@@ -1,9 +1,9 @@
1
1
  import { cx } from "../../cx.js";
2
2
  import { positionKeys } from "../../components/keys.js";
3
3
  import { usePersistedState } from "../../components/Provider.js";
4
+ import { LoadingRegion, Skeleton } from "../../components/Skeleton.js";
4
5
  import { useSplitter } from "../../components/splitter.js";
5
6
  import { Drawer } from "../../components/Drawer.js";
6
- import { LoadingRegion, Skeleton } from "../../components/Skeleton.js";
7
7
  import { SidebarGroupHeading, SidebarPanel, SidebarToggle } from "./sidebar.js";
8
8
  import { jsx, jsxs } from "react/jsx-runtime";
9
9
  import { Fragment as Fragment$1, useEffect, useRef } from "react";
@@ -0,0 +1,33 @@
1
+ /** One step of the trail to the current page. */
2
+ export interface Breadcrumb {
3
+ /** Empty while the name is still loading: the trail shows a placeholder. */
4
+ label: string;
5
+ href?: string | undefined;
6
+ }
7
+ export interface BreadcrumbsProps {
8
+ /** The trail, ending with where the reader is. A crumb with an empty label,
9
+ * such as a name still loading, shows a placeholder. */
10
+ crumbs: readonly Breadcrumb[];
11
+ /** Goes to a crumb that has no `href`: a place with no URL, such as a folder
12
+ * in a view that keeps its path out of the URL. A crumb with a URL takes
13
+ * `href` instead, so it opens in a new tab. Without either, a crumb is
14
+ * text. */
15
+ onSelect?: ((crumb: Breadcrumb, index: number) => void) | undefined;
16
+ /** What the last crumb is: the page itself, or a place within the page, such
17
+ * as the open folder. */
18
+ current?: 'page' | 'location' | undefined;
19
+ /** Makes the last crumb the page's `h1`, as `PageHeader` does. A page has
20
+ * one. */
21
+ heading?: boolean | undefined;
22
+ /** Names the landmark; the strings' `breadcrumb` by default. */
23
+ label?: string | undefined;
24
+ className?: string | undefined;
25
+ }
26
+ /**
27
+ * The trail to where the reader is, as a list in a `nav` landmark. A trail too
28
+ * wide for its room folds its ancestors into a "…" menu, keeping the parent
29
+ * when it fits beside the last crumb, which truncates last, with its full name
30
+ * on hover. In a flex row its room is what the row's other items leave it;
31
+ * elsewhere it fills its container's width.
32
+ */
33
+ export declare function Breadcrumbs({ crumbs, onSelect, current, heading, label, className, }: BreadcrumbsProps): import("react").JSX.Element;
@@ -0,0 +1,203 @@
1
+ import { cx } from "../cx.js";
2
+ import { buttonClasses } from "./buttonClasses.js";
3
+ import { ChevronRightIcon, MoreIcon } from "../icons.js";
4
+ import { keyedByContent } from "./keys.js";
5
+ import { Tooltip } from "./Tooltip.js";
6
+ import { useStrings } from "./Provider.js";
7
+ import { AppLink } from "./AppLink.js";
8
+ import { ActionMenu } from "./ActionMenu.js";
9
+ import { Skeleton } from "./Skeleton.js";
10
+ import { jsx, jsxs } from "react/jsx-runtime";
11
+ import { useLayoutEffect, useState } from "react";
12
+ //#region src/components/Breadcrumbs.tsx
13
+ /**
14
+ * The trail to where the reader is, as a list in a `nav` landmark. A trail too
15
+ * wide for its room folds its ancestors into a "…" menu, keeping the parent
16
+ * when it fits beside the last crumb, which truncates last, with its full name
17
+ * on hover. In a flex row its room is what the row's other items leave it;
18
+ * elsewhere it fills its container's width.
19
+ */
20
+ function Breadcrumbs({ crumbs, onSelect, current = "page", heading = false, label, className }) {
21
+ const { common } = useStrings();
22
+ const [nav, setNav] = useState(null);
23
+ const [natural, setNatural] = useState(null);
24
+ const fit = useTrailFit(nav, natural);
25
+ const last = crumbs[crumbs.length - 1];
26
+ const ancestors = crumbs.slice(0, -1);
27
+ const folded = fit === "none" ? ancestors : fit === "parent" ? ancestors.slice(0, -1) : [];
28
+ const shown = ancestors.slice(folded.length);
29
+ const Last = heading ? "h1" : "span";
30
+ const menuItem = (crumb, index) => {
31
+ const name = crumb.label || common.loading;
32
+ if (crumb.href) return {
33
+ label: name,
34
+ href: crumb.href
35
+ };
36
+ if (onSelect) return {
37
+ label: name,
38
+ onClick: () => onSelect(crumb, index)
39
+ };
40
+ return {
41
+ label: name,
42
+ disabled: true,
43
+ onClick: () => {}
44
+ };
45
+ };
46
+ return /* @__PURE__ */ jsxs("nav", {
47
+ ref: setNav,
48
+ "aria-label": label ?? common.breadcrumb,
49
+ className: cx("relative min-w-0", className),
50
+ children: [/* @__PURE__ */ jsxs("ol", {
51
+ className: "m-0 flex min-w-0 list-none items-center gap-1.5 p-0 text-[13px] leading-none",
52
+ children: [
53
+ folded.length > 0 && /* @__PURE__ */ jsxs("li", {
54
+ className: "flex shrink-0 items-center gap-1.5",
55
+ children: [/* @__PURE__ */ jsx(ActionMenu, {
56
+ label: common.showFullPath,
57
+ align: "start",
58
+ items: folded.map(menuItem),
59
+ triggerClassName: buttonClasses({
60
+ variant: "ghost",
61
+ size: "xs",
62
+ iconOnly: true
63
+ }),
64
+ children: /* @__PURE__ */ jsx(MoreIcon, {})
65
+ }), /* @__PURE__ */ jsx(Separator, {})]
66
+ }),
67
+ keyedByContent(shown, (c) => c.href ?? c.label).map(({ key, item: crumb }, i) => {
68
+ const index = folded.length + i;
69
+ return /* @__PURE__ */ jsxs("li", {
70
+ className: cx("flex items-center gap-1.5", folded.length > 0 ? "min-w-0 max-w-36 shrink-0" : "min-w-0"),
71
+ children: [crumb.label === "" ? /* @__PURE__ */ jsx(CrumbPlaceholder, {}) : /* @__PURE__ */ jsx(Tooltip, {
72
+ content: crumb.label,
73
+ overflowOnly: true,
74
+ children: crumb.href ? /* @__PURE__ */ jsx(AppLink, {
75
+ href: crumb.href,
76
+ className: cx(crumbText, crumbLink, "max-w-48"),
77
+ children: crumb.label
78
+ }) : onSelect ? /* @__PURE__ */ jsx("button", {
79
+ type: "button",
80
+ onClick: () => onSelect(crumb, index),
81
+ className: cx(crumbText, crumbLink, "max-w-48 cursor-pointer text-start"),
82
+ children: crumb.label
83
+ }) : /* @__PURE__ */ jsx("span", {
84
+ className: cx(crumbText, "max-w-48"),
85
+ children: crumb.label
86
+ })
87
+ }), /* @__PURE__ */ jsx(Separator, {})]
88
+ }, key);
89
+ }),
90
+ last && /* @__PURE__ */ jsx("li", {
91
+ className: "flex min-w-0 items-center",
92
+ children: last.label === "" ? /* @__PURE__ */ jsx(Last, {
93
+ "aria-current": current,
94
+ className: "m-0",
95
+ children: /* @__PURE__ */ jsx(CrumbPlaceholder, {})
96
+ }) : /* @__PURE__ */ jsx(Tooltip, {
97
+ content: last.label,
98
+ overflowOnly: true,
99
+ children: /* @__PURE__ */ jsx(Last, {
100
+ "aria-current": current,
101
+ className: "m-0 block truncate text-[13px] font-medium leading-none text-(--theme-app)",
102
+ children: last.label
103
+ })
104
+ })
105
+ })
106
+ ]
107
+ }), /* @__PURE__ */ jsx("div", {
108
+ "aria-hidden": true,
109
+ className: "pointer-events-none invisible absolute inset-0 overflow-hidden",
110
+ children: /* @__PURE__ */ jsx("div", {
111
+ ref: setNatural,
112
+ className: "flex w-max items-center gap-1.5 whitespace-nowrap text-[13px] leading-none",
113
+ children: crumbs.map((crumb, i) => /* @__PURE__ */ jsxs("span", {
114
+ className: "flex items-center gap-1.5",
115
+ children: [/* @__PURE__ */ jsx("span", {
116
+ className: cx(i < crumbs.length - 1 && "max-w-48 truncate", i === crumbs.length - 1 && "font-medium"),
117
+ children: crumb.label || " ".repeat(6)
118
+ }), i < crumbs.length - 1 && /* @__PURE__ */ jsx(Separator, {})]
119
+ }, i))
120
+ })
121
+ })]
122
+ });
123
+ }
124
+ var crumbText = "block truncate text-(--theme-muted-text-color)";
125
+ var crumbLink = "rounded-sm transition-colors hover:text-(--theme-app) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--theme-link)";
126
+ function Separator() {
127
+ return /* @__PURE__ */ jsx(ChevronRightIcon, { className: "size-3 shrink-0 text-(--theme-muted-text-color) opacity-60 rtl:rotate-180" });
128
+ }
129
+ function CrumbPlaceholder() {
130
+ const { common } = useStrings();
131
+ return /* @__PURE__ */ jsxs("span", {
132
+ className: "flex items-center",
133
+ children: [/* @__PURE__ */ jsx(Skeleton, {
134
+ width: "6rem",
135
+ height: "0.8125rem"
136
+ }), /* @__PURE__ */ jsx("span", {
137
+ className: "sr-only",
138
+ children: common.loading
139
+ })]
140
+ });
141
+ }
142
+ var FOLD_WIDTH = 48;
143
+ var PARENT_MAX_WIDTH = 144;
144
+ var LAST_MIN_WIDTH = 128;
145
+ var px = (value) => Number.parseFloat(value) || 0;
146
+ /** The width the trail may take. A flex row sizes the trail to its content, so
147
+ * its room is what the row leaves beside its other items; anywhere else the
148
+ * `nav` is a block that fills its container. */
149
+ function roomFor(nav, row) {
150
+ const style = getComputedStyle(row);
151
+ if (!style.display.endsWith("flex") || style.flexDirection.startsWith("column")) return nav.clientWidth;
152
+ const gap = px(style.columnGap);
153
+ let room = row.clientWidth - px(style.paddingLeft) - px(style.paddingRight);
154
+ for (const child of row.children) if (child !== nav) room -= child.getBoundingClientRect().width + gap;
155
+ return room;
156
+ }
157
+ /**
158
+ * Fits the trail to its room. It measures a hidden copy of the trail at its
159
+ * natural width, so the answer doesn't depend on how the trail is folded at the
160
+ * moment.
161
+ */
162
+ function useTrailFit(nav, natural) {
163
+ const [fit, setFit] = useState("all");
164
+ useLayoutEffect(() => {
165
+ const row = nav?.parentElement;
166
+ if (!nav || !natural || !row) return;
167
+ const measure = () => {
168
+ const room = roomFor(nav, row);
169
+ if (natural.scrollWidth <= room) {
170
+ setFit("all");
171
+ return;
172
+ }
173
+ const steps = [...natural.children];
174
+ const width = (el) => el?.getBoundingClientRect().width ?? 0;
175
+ const parent = Math.min(width(steps[steps.length - 2]), PARENT_MAX_WIDTH);
176
+ const last = Math.min(width(steps[steps.length - 1]), LAST_MIN_WIDTH);
177
+ setFit(FOLD_WIDTH + parent + last <= room ? "parent" : "none");
178
+ };
179
+ const resize = new ResizeObserver(measure);
180
+ const observe = () => {
181
+ for (const el of [
182
+ row,
183
+ nav,
184
+ natural,
185
+ ...row.children
186
+ ]) resize.observe(el);
187
+ };
188
+ const added = new MutationObserver(() => {
189
+ observe();
190
+ measure();
191
+ });
192
+ measure();
193
+ observe();
194
+ added.observe(row, { childList: true });
195
+ return () => {
196
+ resize.disconnect();
197
+ added.disconnect();
198
+ };
199
+ }, [nav, natural]);
200
+ return fit;
201
+ }
202
+ //#endregion
203
+ export { Breadcrumbs };
@@ -9,6 +9,8 @@ export interface ColorInputProps {
9
9
  label?: ReactNode;
10
10
  /** Beside the label and outside it, so it stays out of the field's name. */
11
11
  hint?: ReactNode;
12
+ /** Buttons at the far end of the label's row that act on the color, as Field's `actions`. */
13
+ actions?: ReactNode;
12
14
  /** Help under the field. */
13
15
  description?: ReactNode;
14
16
  error?: ReactNode;
@@ -33,4 +35,4 @@ export interface ColorInputProps {
33
35
  * in. A half-typed color stays in the field, marked invalid, until it parses; leaving the field
34
36
  * puts back the last color that did.
35
37
  */
36
- export declare function ColorInput({ value, onValueChange, label, hint, description, error, size, id, name, disabled, required, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, className, }: ColorInputProps): string | number | bigint | boolean | import("react").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | Iterable<ReactNode> | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | import("react").ReactPortal | null | undefined> | null | undefined;
38
+ export declare function ColorInput({ value, onValueChange, label, hint, actions, description, error, size, id, name, disabled, required, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, className, }: ColorInputProps): string | number | bigint | boolean | import("react").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | Iterable<ReactNode> | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | import("react").ReactPortal | null | undefined> | null | undefined;
@@ -14,7 +14,7 @@ var swatchClassName = cx("shrink-0 cursor-pointer p-0.5", "[&::-webkit-color-swa
14
14
  * in. A half-typed color stays in the field, marked invalid, until it parses; leaving the field
15
15
  * puts back the last color that did.
16
16
  */
17
- function ColorInput({ value, onValueChange, label, hint, description, error, size = "md", id, name, disabled, required, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, className }) {
17
+ function ColorInput({ value, onValueChange, label, hint, actions, description, error, size = "md", id, name, disabled, required, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, className }) {
18
18
  const t = useStrings().colorInput;
19
19
  const [draft, setDraft] = useState(null);
20
20
  const errorId = useId();
@@ -25,6 +25,7 @@ function ColorInput({ value, onValueChange, label, hint, description, error, siz
25
25
  return framed({
26
26
  label,
27
27
  hint,
28
+ actions,
28
29
  description,
29
30
  error: error ?? (unparsed ? t.invalid : void 0),
30
31
  size
@@ -22,7 +22,9 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
22
22
  ref?: Ref<HTMLTextAreaElement> | undefined;
23
23
  }
24
24
  /** The Field around a control when it has a label; the control alone when it doesn't. */
25
- export declare function framed({ label, hint, help, description, error, size }: Omit<FramedProps, 'mono'>, { id, required, disabled, }: {
25
+ export declare function framed({ label, hint, help, actions, description, error, size, }: Omit<FramedProps, 'mono'> & {
26
+ actions?: ReactNode;
27
+ }, { id, required, disabled, }: {
26
28
  id?: string | undefined;
27
29
  required?: boolean | undefined;
28
30
  disabled?: boolean | undefined;
@@ -6,12 +6,13 @@ import { useState } from "react";
6
6
  import { Input } from "@base-ui/react/input";
7
7
  //#region src/components/Input.tsx
8
8
  /** The Field around a control when it has a label; the control alone when it doesn't. */
9
- function framed({ label, hint, help, description, error, size }, { id, required, disabled }, control) {
9
+ function framed({ label, hint, help, actions, description, error, size }, { id, required, disabled }, control) {
10
10
  if (label === void 0) return control(error ? { "aria-invalid": true } : {});
11
11
  return /* @__PURE__ */ jsx(Field, {
12
12
  label,
13
13
  hint,
14
14
  help,
15
+ actions,
15
16
  description,
16
17
  error,
17
18
  required,
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode } from 'react';
2
- import { type Breadcrumb } from './pageHeaderStore.js';
3
- export type { Breadcrumb } from './pageHeaderStore.js';
2
+ import { type Breadcrumb } from './Breadcrumbs.js';
3
+ export type { Breadcrumb } from './Breadcrumbs.js';
4
4
  /**
5
5
  * Holds the header the current page declares, for the `PageHeaderBar` the
6
6
  * shell renders. Wrap the shell, above both the bar and the routes.
@@ -1,12 +1,6 @@
1
1
  import { cx } from "../cx.js";
2
- import { buttonClasses } from "./buttonClasses.js";
3
- import { ChevronRightIcon, MoreIcon } from "../icons.js";
4
- import { keyedByContent } from "./keys.js";
5
- import { Tooltip } from "./Tooltip.js";
6
- import { useSlots, useStrings } from "./Provider.js";
7
- import { AppLink } from "./AppLink.js";
8
- import { ActionMenu } from "./ActionMenu.js";
9
- import { Skeleton } from "./Skeleton.js";
2
+ import { useSlots } from "./Provider.js";
3
+ import { Breadcrumbs } from "./Breadcrumbs.js";
10
4
  import { PageHeaderStore } from "./pageHeaderStore.js";
11
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
12
6
  import { createContext, use, useCallback, useLayoutEffect, useState, useSyncExternalStore } from "react";
@@ -146,7 +140,10 @@ function HeaderLayout({ breadcrumbs, titleActions, actions, toolbar, compact = f
146
140
  className: "flex min-w-0 flex-1 items-center gap-2",
147
141
  children: [
148
142
  navToggle?.(),
149
- hasTrail && /* @__PURE__ */ jsx(BreadcrumbTrail, { crumbs: breadcrumbs }),
143
+ hasTrail && /* @__PURE__ */ jsx(Breadcrumbs, {
144
+ crumbs: breadcrumbs,
145
+ heading: true
146
+ }),
150
147
  present(titleActions) && /* @__PURE__ */ jsx("div", {
151
148
  className: "flex shrink-0 items-center gap-0.5",
152
149
  children: titleActions
@@ -163,141 +160,5 @@ function HeaderLayout({ breadcrumbs, titleActions, actions, toolbar, compact = f
163
160
  })]
164
161
  });
165
162
  }
166
- var crumbText = "block truncate text-(--theme-muted-text-color)";
167
- var crumbLink = "rounded-sm transition-colors hover:text-(--theme-app) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--theme-link)";
168
- function Separator() {
169
- return /* @__PURE__ */ jsx(ChevronRightIcon, { className: "size-3 shrink-0 text-(--theme-muted-text-color) opacity-60" });
170
- }
171
- var FOLD_WIDTH = 48;
172
- var PARENT_MAX_WIDTH = 144;
173
- var TITLE_MIN_WIDTH = 128;
174
- /**
175
- * Fits the trail to the room its row leaves it. It measures a hidden copy of
176
- * the trail at its natural width against the row's width less its other items,
177
- * so the answer doesn't depend on how the trail is folded at the moment.
178
- */
179
- function useTrailFit(nav, natural) {
180
- const [fit, setFit] = useState("all");
181
- useLayoutEffect(() => {
182
- const row = nav?.parentElement;
183
- if (!nav || !natural || !row) return;
184
- const measure = () => {
185
- const gap = Number.parseFloat(getComputedStyle(row).columnGap) || 0;
186
- let room = row.clientWidth;
187
- for (const child of row.children) if (child !== nav) room -= child.getBoundingClientRect().width + gap;
188
- if (natural.scrollWidth <= room) {
189
- setFit("all");
190
- return;
191
- }
192
- const steps = [...natural.children];
193
- const width = (el) => el?.getBoundingClientRect().width ?? 0;
194
- const parent = Math.min(width(steps[steps.length - 2]), PARENT_MAX_WIDTH);
195
- const title = Math.min(width(steps[steps.length - 1]), TITLE_MIN_WIDTH);
196
- setFit(FOLD_WIDTH + parent + title <= room ? "parent" : "none");
197
- };
198
- measure();
199
- const observer = new ResizeObserver(measure);
200
- observer.observe(row);
201
- observer.observe(natural);
202
- return () => observer.disconnect();
203
- }, [nav, natural]);
204
- return fit;
205
- }
206
- function BreadcrumbTrail({ crumbs }) {
207
- const { common } = useStrings();
208
- const [nav, setNav] = useState(null);
209
- const [natural, setNatural] = useState(null);
210
- const fit = useTrailFit(nav, natural);
211
- const current = crumbs[crumbs.length - 1];
212
- const ancestors = crumbs.slice(0, -1);
213
- const folded = fit === "none" ? ancestors : fit === "parent" ? ancestors.slice(0, -1) : [];
214
- const shown = ancestors.slice(folded.length);
215
- return /* @__PURE__ */ jsxs("nav", {
216
- ref: setNav,
217
- "aria-label": common.breadcrumb,
218
- className: "relative min-w-0",
219
- children: [/* @__PURE__ */ jsxs("ol", {
220
- className: "m-0 flex min-w-0 list-none items-center gap-1.5 p-0 text-[13px] leading-none",
221
- children: [
222
- folded.length > 0 && /* @__PURE__ */ jsxs("li", {
223
- className: "flex shrink-0 items-center gap-1.5",
224
- children: [/* @__PURE__ */ jsx(ActionMenu, {
225
- label: common.showFullPath,
226
- align: "start",
227
- items: folded.map((crumb) => crumb.href ? {
228
- label: crumb.label,
229
- href: crumb.href
230
- } : {
231
- label: crumb.label,
232
- disabled: true,
233
- onClick: () => {}
234
- }),
235
- triggerClassName: buttonClasses({
236
- variant: "ghost",
237
- size: "xs",
238
- iconOnly: true
239
- }),
240
- children: /* @__PURE__ */ jsx(MoreIcon, {})
241
- }), /* @__PURE__ */ jsx(Separator, {})]
242
- }),
243
- keyedByContent(shown, (c) => c.href ?? c.label).map(({ key, item: crumb }) => /* @__PURE__ */ jsxs("li", {
244
- className: cx("flex items-center gap-1.5", folded.length > 0 ? "min-w-0 max-w-36 shrink-0" : "min-w-0"),
245
- children: [crumb.label === "" ? /* @__PURE__ */ jsx(CrumbPlaceholder, {}) : /* @__PURE__ */ jsx(Tooltip, {
246
- content: crumb.label,
247
- overflowOnly: true,
248
- children: crumb.href ? /* @__PURE__ */ jsx(AppLink, {
249
- href: crumb.href,
250
- className: cx(crumbText, crumbLink, "max-w-48"),
251
- children: crumb.label
252
- }) : /* @__PURE__ */ jsx("span", {
253
- className: cx(crumbText, "max-w-48"),
254
- children: crumb.label
255
- })
256
- }), /* @__PURE__ */ jsx(Separator, {})]
257
- }, key)),
258
- current && /* @__PURE__ */ jsx("li", {
259
- className: "flex min-w-0 items-center",
260
- children: current.label === "" ? /* @__PURE__ */ jsx("h1", {
261
- "aria-current": "page",
262
- className: "m-0",
263
- children: /* @__PURE__ */ jsx(CrumbPlaceholder, {})
264
- }) : /* @__PURE__ */ jsx(Tooltip, {
265
- content: current.label,
266
- overflowOnly: true,
267
- children: /* @__PURE__ */ jsx("h1", {
268
- "aria-current": "page",
269
- className: "m-0 truncate text-[13px] font-medium leading-none text-(--theme-app)",
270
- children: current.label
271
- })
272
- })
273
- })
274
- ]
275
- }), /* @__PURE__ */ jsx("div", {
276
- ref: setNatural,
277
- "aria-hidden": true,
278
- className: "invisible absolute top-0 left-0 flex items-center gap-1.5 whitespace-nowrap text-[13px] leading-none",
279
- children: crumbs.map((crumb, i) => /* @__PURE__ */ jsxs("span", {
280
- className: "flex items-center gap-1.5",
281
- children: [/* @__PURE__ */ jsx("span", {
282
- className: cx(i < crumbs.length - 1 && "max-w-48 truncate", i === crumbs.length - 1 && "font-medium"),
283
- children: crumb.label || " ".repeat(6)
284
- }), i < crumbs.length - 1 && /* @__PURE__ */ jsx(Separator, {})]
285
- }, i))
286
- })]
287
- });
288
- }
289
- function CrumbPlaceholder() {
290
- const { common } = useStrings();
291
- return /* @__PURE__ */ jsxs("span", {
292
- className: "flex items-center",
293
- children: [/* @__PURE__ */ jsx(Skeleton, {
294
- width: "6rem",
295
- height: "0.8125rem"
296
- }), /* @__PURE__ */ jsx("span", {
297
- className: "sr-only",
298
- children: common.loading
299
- })]
300
- });
301
- }
302
163
  //#endregion
303
164
  export { PageHeader, PageHeaderBar, PageHeaderProvider };
@@ -153,6 +153,8 @@ export interface UIStrings {
153
153
  /** The background a theme is generated from. */
154
154
  base: string;
155
155
  accent: string;
156
+ /** Exchanges the background and the accent. */
157
+ swap: string;
156
158
  contrast: string;
157
159
  /** Under the contrast slider. */
158
160
  contrastHelp: string;
@@ -639,7 +641,7 @@ export interface UIData {
639
641
  usePersistedState: PersistedStateHook;
640
642
  }
641
643
  import type { FieldComponent } from '../form/fieldRegistry.js';
642
- import type { Breadcrumb } from './pageHeaderStore.js';
644
+ import type { Breadcrumb } from './Breadcrumbs.js';
643
645
  /** An internal navigation link, which hosts bind to their router's Link. Its
644
646
  * `href` is a full in-app URL that may carry a search and a hash, such as
645
647
  * `/runs/42?tab=logs#step-3`, and the host routes all of it client-side on a
@@ -153,6 +153,7 @@ var DEFAULTS = {
153
153
  presets: "Presets",
154
154
  base: "Background",
155
155
  accent: "Accent",
156
+ swap: "Swap background and accent",
156
157
  contrast: "Contrast",
157
158
  contrastHelp: "Separates surfaces and strengthens text. Text always meets WCAG AA.",
158
159
  customSidebar: "Custom sidebar",
@@ -1,7 +1,9 @@
1
1
  import { cx } from "../cx.js";
2
+ import { SwapIcon } from "../icons.js";
2
3
  import { useStrings } from "./Provider.js";
3
4
  import { useCopy } from "./useCopy.js";
4
5
  import { Button } from "./Button.js";
6
+ import { IconButton } from "./IconButton.js";
5
7
  import { fieldBox } from "./fieldBox.js";
6
8
  import { parseColor, toHex } from "../theme/color.js";
7
9
  import { CODE_STYLES, DEFAULT_THEME, THEME_PRESETS, modeOf, parseTheme, schemeFor, serializeTheme } from "../theme/model.js";
@@ -110,6 +112,14 @@ function ThemeEditor({ value, onChange, mode, onModeChange, preference, onPrefer
110
112
  }),
111
113
  /* @__PURE__ */ jsx(ColorInput, {
112
114
  label: t.accent,
115
+ actions: /* @__PURE__ */ jsx(IconButton, {
116
+ icon: /* @__PURE__ */ jsx(SwapIcon, { className: "rotate-90" }),
117
+ label: t.swap,
118
+ onClick: () => setSeed({
119
+ base: seed.accent,
120
+ accent: seed.base
121
+ })
122
+ }),
113
123
  value: seed.accent,
114
124
  onValueChange: (accent) => setSeed({ accent })
115
125
  }),
@@ -1,9 +1,4 @@
1
- /** One step of the trail to the current page. */
2
- export interface Breadcrumb {
3
- /** Empty while the name is still loading: the bar shows a placeholder. */
4
- label: string;
5
- href?: string | undefined;
6
- }
1
+ import type { Breadcrumb } from './Breadcrumbs.js';
7
2
  export type PageHeaderRegion = 'titleActions' | 'actions' | 'toolbar';
8
3
  /** The nodes a declaration portals its regions into. They are created with the
9
4
  * declaration, before any bar exists, so its content renders on the first pass