@parallelworks/ui 0.91.0 → 0.93.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.
@@ -0,0 +1,369 @@
1
+ import { cx } from "../cx.js";
2
+ import { CloseIcon, SidebarCloseIcon, SidebarOpenIcon } from "../icons.js";
3
+ import { Tooltip } from "../components/Tooltip.js";
4
+ import { AppLink, isPlainClick } from "../components/AppLink.js";
5
+ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
6
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
+ import { Dialog } from "@base-ui/react/dialog";
8
+ //#region src/app-frame/index.tsx
9
+ var defaultLabels = {
10
+ navigation: "Main navigation",
11
+ skipToContent: "Skip to content",
12
+ openNavigation: "Open navigation",
13
+ closeNavigation: "Close navigation",
14
+ expandSidebar: "Expand sidebar",
15
+ collapseSidebar: "Collapse sidebar",
16
+ resizeSidebar: "Resize sidebar"
17
+ };
18
+ var Context = createContext(null);
19
+ function useAppFrame() {
20
+ const context = useContext(Context);
21
+ if (!context) throw new Error("useAppFrame requires AppFrame");
22
+ return context;
23
+ }
24
+ function useFrameContext() {
25
+ const context = useContext(Context);
26
+ if (!context) throw new Error("App frame controls require AppFrame");
27
+ return context;
28
+ }
29
+ function useMobile(breakpoint) {
30
+ const media = useMemo(() => typeof window === "undefined" ? null : window.matchMedia(`(max-width: ${breakpoint - 1}px)`), [breakpoint]);
31
+ return useSyncExternalStore(useMemo(() => (changed) => {
32
+ media?.addEventListener("change", changed);
33
+ return () => media?.removeEventListener("change", changed);
34
+ }, [media]), () => media?.matches ?? false, () => false);
35
+ }
36
+ function slot(value, state) {
37
+ return typeof value === "function" ? value(state) : value;
38
+ }
39
+ /** An inset application surface beside a resizable icon rail or navigation column.
40
+ * Router, identity, persistence and permissions belong to the host. Import app-frame.css. */
41
+ function AppFrame({ children, brand, navigation, footer, header, banner, expanded: controlledExpanded, defaultExpanded = true, onExpandedChange, width: controlledWidth, defaultWidth = 240, onWidthChange, minWidth = 200, maxWidth = 400, railWidth = 64, resizable = true, mobileBreakpoint = 768, navigationKey, navigationResetKey = navigationKey, sidebarProps, fullBleed = false, labels: labelOverrides, mainId: suppliedMainId, mainClassName, className, style, ...props }) {
42
+ const id = useId();
43
+ const mainId = suppliedMainId ?? `${id}-main`;
44
+ const navigationId = `${id}-navigation`;
45
+ const [ownExpanded, setOwnExpanded] = useState(defaultExpanded);
46
+ const [ownWidth, setOwnWidth] = useState(defaultWidth);
47
+ const [dragWidth, setDragWidth] = useState(null);
48
+ const [mobileOpen, setMobileOpen] = useState(false);
49
+ const [resizing, setResizing] = useState(false);
50
+ const desktopExpanded = controlledExpanded ?? ownExpanded;
51
+ const minimum = Math.max(railWidth, minWidth);
52
+ const maximum = Math.max(minimum, maxWidth);
53
+ const clamp = (n) => Math.min(maximum, Math.max(minimum, Number.isFinite(n) ? n : defaultWidth));
54
+ const width = clamp(dragWidth ?? controlledWidth ?? ownWidth);
55
+ const isMobile = useMobile(mobileBreakpoint);
56
+ const labels = {
57
+ ...defaultLabels,
58
+ ...labelOverrides
59
+ };
60
+ const navRef = useRef(null);
61
+ const scrollRef = useRef(null);
62
+ const savedScrollTop = useRef(0);
63
+ const restoreScroll = useCallback((node) => {
64
+ scrollRef.current = node;
65
+ if (node) node.scrollTop = savedScrollTop.current;
66
+ }, []);
67
+ const portalRef = useRef(null);
68
+ const drag = useRef(null);
69
+ useEffect(() => {
70
+ if (!isMobile && resizable) return;
71
+ drag.current = null;
72
+ setDragWidth(null);
73
+ setResizing(false);
74
+ }, [isMobile, resizable]);
75
+ const setExpanded = (next) => {
76
+ setOwnExpanded(next);
77
+ onExpandedChange?.(next);
78
+ };
79
+ const setWidth = (next) => {
80
+ const value = clamp(next);
81
+ setOwnWidth(value);
82
+ onWidthChange?.(value);
83
+ };
84
+ const closeNavigation = () => setMobileOpen(false);
85
+ const state = {
86
+ expanded: isMobile || desktopExpanded,
87
+ isMobile,
88
+ mobileOpen,
89
+ toggle: () => isMobile ? setMobileOpen(!mobileOpen) : setExpanded(!desktopExpanded),
90
+ closeNavigation,
91
+ labels,
92
+ navigationId
93
+ };
94
+ const previousLocation = useRef({
95
+ navigationKey,
96
+ navigationResetKey,
97
+ isMobile
98
+ });
99
+ useEffect(() => {
100
+ if (previousLocation.current.navigationKey === navigationKey && previousLocation.current.navigationResetKey === navigationResetKey && previousLocation.current.isMobile === isMobile) return;
101
+ if (previousLocation.current.navigationKey !== navigationKey || previousLocation.current.isMobile !== isMobile) setMobileOpen(false);
102
+ if (previousLocation.current.navigationResetKey !== navigationResetKey || previousLocation.current.isMobile !== isMobile) {
103
+ savedScrollTop.current = 0;
104
+ scrollRef.current?.scrollTo({ top: 0 });
105
+ }
106
+ previousLocation.current = {
107
+ navigationKey,
108
+ navigationResetKey,
109
+ isMobile
110
+ };
111
+ }, [
112
+ navigationKey,
113
+ navigationResetKey,
114
+ isMobile
115
+ ]);
116
+ const navContent = /* @__PURE__ */ jsxs(Fragment$1, { children: [
117
+ brand && /* @__PURE__ */ jsx("div", {
118
+ className: "pw-frame-brand",
119
+ children: slot(brand, state)
120
+ }),
121
+ /* @__PURE__ */ jsx("div", {
122
+ className: "pw-frame-links",
123
+ ref: restoreScroll,
124
+ onScroll: (event) => {
125
+ savedScrollTop.current = event.currentTarget.scrollTop;
126
+ },
127
+ children: slot(navigation, state)
128
+ }),
129
+ footer && /* @__PURE__ */ jsx("div", {
130
+ className: "pw-frame-footer",
131
+ children: slot(footer, state)
132
+ })
133
+ ] });
134
+ const resize = (clientX, pointerId) => {
135
+ const current = drag.current;
136
+ if (!current || current.pointerId !== pointerId) return;
137
+ const next = (clientX - current.origin) * current.direction;
138
+ if (desktopExpanded && next < minimum - 80) setExpanded(false);
139
+ else if (!desktopExpanded && next >= minimum - 50) {
140
+ setExpanded(true);
141
+ current.width = clamp(next);
142
+ setDragWidth(current.width);
143
+ } else if (desktopExpanded) {
144
+ current.width = clamp(next);
145
+ setDragWidth(current.width);
146
+ }
147
+ };
148
+ const endResize = (event) => {
149
+ const ended = drag.current;
150
+ if (!ended || ended.pointerId !== event.pointerId) return;
151
+ drag.current = null;
152
+ setResizing(false);
153
+ setDragWidth(null);
154
+ if (desktopExpanded && ended.width !== ended.startWidth) setWidth(ended.width);
155
+ };
156
+ return /* @__PURE__ */ jsx(Context, {
157
+ value: state,
158
+ children: /* @__PURE__ */ jsxs("div", {
159
+ ...props,
160
+ className: cx("pw-app-frame", className),
161
+ "data-mobile": isMobile || void 0,
162
+ "data-full-bleed": fullBleed || void 0,
163
+ style,
164
+ children: [
165
+ /* @__PURE__ */ jsx("a", {
166
+ className: "pw-frame-skip",
167
+ href: `#${mainId}`,
168
+ onClick: (event) => {
169
+ event.preventDefault();
170
+ document.getElementById(mainId)?.focus();
171
+ },
172
+ children: labels.skipToContent
173
+ }),
174
+ banner && /* @__PURE__ */ jsx("div", {
175
+ className: "pw-frame-banner",
176
+ children: banner
177
+ }),
178
+ /* @__PURE__ */ jsxs("div", {
179
+ className: "pw-frame-body",
180
+ children: [
181
+ !isMobile && /* @__PURE__ */ jsxs("aside", {
182
+ ...sidebarProps,
183
+ ref: navRef,
184
+ id: navigationId,
185
+ className: cx("pw-frame-sidebar", sidebarProps?.className),
186
+ "data-sidebar-mode": desktopExpanded ? "expanded" : "rail",
187
+ "data-resizing": resizing || void 0,
188
+ style: {
189
+ ...sidebarProps?.style,
190
+ width: desktopExpanded ? width : railWidth
191
+ },
192
+ children: [navContent, resizable && /* @__PURE__ */ jsx("div", {
193
+ role: "separator",
194
+ tabIndex: 0,
195
+ "aria-orientation": "vertical",
196
+ "aria-label": labels.resizeSidebar,
197
+ "aria-controls": navigationId,
198
+ "aria-valuemin": railWidth,
199
+ "aria-valuemax": maximum,
200
+ "aria-valuenow": Math.round(desktopExpanded ? width : railWidth),
201
+ className: "pw-frame-resize",
202
+ onPointerDown: (event) => {
203
+ if (event.button !== 0 || event.isPrimary === false || drag.current) return;
204
+ event.preventDefault();
205
+ const rect = navRef.current?.getBoundingClientRect();
206
+ if (!rect) return;
207
+ const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
208
+ drag.current = {
209
+ origin: rtl ? rect.right : rect.left,
210
+ direction: rtl ? -1 : 1,
211
+ pointerId: event.pointerId,
212
+ startWidth: width,
213
+ width
214
+ };
215
+ event.currentTarget.setPointerCapture(event.pointerId);
216
+ setResizing(true);
217
+ },
218
+ onPointerMove: (event) => resize(event.clientX, event.pointerId),
219
+ onPointerUp: endResize,
220
+ onPointerCancel: endResize,
221
+ onLostPointerCapture: endResize,
222
+ onDoubleClick: () => {
223
+ setExpanded(true);
224
+ setWidth(defaultWidth);
225
+ },
226
+ onKeyDown: (event) => {
227
+ const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
228
+ const smaller = rtl ? "ArrowRight" : "ArrowLeft";
229
+ if (![
230
+ smaller,
231
+ rtl ? "ArrowLeft" : "ArrowRight",
232
+ "Home",
233
+ "End"
234
+ ].includes(event.key)) return;
235
+ event.preventDefault();
236
+ if (event.key === "Home") {
237
+ setExpanded(true);
238
+ setWidth(minimum);
239
+ } else if (event.key === "End") {
240
+ setExpanded(true);
241
+ setWidth(maximum);
242
+ } else if (event.key === smaller) {
243
+ if (width <= minimum) setExpanded(false);
244
+ else if (desktopExpanded) setWidth(width - 16);
245
+ } else if (!desktopExpanded) setExpanded(true);
246
+ else setWidth(width + 16);
247
+ }
248
+ })]
249
+ }),
250
+ /* @__PURE__ */ jsx("div", {
251
+ className: "pw-frame-surface-wrap",
252
+ children: /* @__PURE__ */ jsxs("div", {
253
+ className: "pw-frame-surface",
254
+ children: [header && /* @__PURE__ */ jsx("div", {
255
+ className: "pw-frame-header",
256
+ children: header
257
+ }), /* @__PURE__ */ jsx("main", {
258
+ id: mainId,
259
+ tabIndex: -1,
260
+ className: cx("pw-frame-main", mainClassName),
261
+ children
262
+ })]
263
+ })
264
+ }),
265
+ /* @__PURE__ */ jsx("div", { ref: portalRef })
266
+ ]
267
+ }),
268
+ isMobile && /* @__PURE__ */ jsx(Dialog.Root, {
269
+ open: mobileOpen,
270
+ onOpenChange: setMobileOpen,
271
+ children: /* @__PURE__ */ jsxs(Dialog.Portal, {
272
+ container: portalRef,
273
+ children: [/* @__PURE__ */ jsx(Dialog.Backdrop, { className: "pw-frame-backdrop" }), /* @__PURE__ */ jsxs(Dialog.Popup, {
274
+ ...sidebarProps,
275
+ id: navigationId,
276
+ className: cx("pw-frame-drawer", sidebarProps?.className),
277
+ "aria-label": labels.navigation,
278
+ "aria-modal": "true",
279
+ "data-sidebar-mode": "drawer",
280
+ style: {
281
+ ...sidebarProps?.style,
282
+ width
283
+ },
284
+ children: [/* @__PURE__ */ jsx("div", {
285
+ className: "pw-frame-mobile-close",
286
+ children: /* @__PURE__ */ jsx(Dialog.Close, {
287
+ className: "pw-frame-toggle",
288
+ "aria-label": labels.closeNavigation,
289
+ children: /* @__PURE__ */ jsx(CloseIcon, {})
290
+ })
291
+ }), navContent]
292
+ })]
293
+ })
294
+ })
295
+ ]
296
+ })
297
+ });
298
+ }
299
+ /** Put in the page header on mobile, and the sidebar footer on desktop. */
300
+ function AppFrameToggle({ className, ...props }) {
301
+ const { expanded, isMobile, mobileOpen, toggle, labels, navigationId } = useFrameContext();
302
+ const label = isMobile ? mobileOpen ? labels.closeNavigation : labels.openNavigation : expanded ? labels.collapseSidebar : labels.expandSidebar;
303
+ return /* @__PURE__ */ jsx(Tooltip, {
304
+ content: label,
305
+ side: isMobile ? "bottom" : "right",
306
+ children: /* @__PURE__ */ jsx("button", {
307
+ ...props,
308
+ type: "button",
309
+ className: cx("pw-frame-toggle", className),
310
+ "aria-label": label,
311
+ "aria-expanded": isMobile ? mobileOpen : expanded,
312
+ "aria-controls": navigationId,
313
+ onClick: (event) => {
314
+ props.onClick?.(event);
315
+ if (!event.defaultPrevented) toggle();
316
+ },
317
+ children: expanded && !isMobile ? /* @__PURE__ */ jsx(SidebarCloseIcon, {}) : /* @__PURE__ */ jsx(SidebarOpenIcon, {})
318
+ })
319
+ });
320
+ }
321
+ /** Router-aware navigation that keeps icon-only rail items named and discoverable. */
322
+ function AppFrameNavItem({ label, icon, selected, badge, disabled, onClick, className, ...target }) {
323
+ const { expanded, closeNavigation } = useFrameContext();
324
+ const props = {
325
+ className: cx("pw-frame-nav-item", className),
326
+ "aria-current": selected ? "page" : void 0,
327
+ onClick: (event) => {
328
+ if (disabled) {
329
+ event.preventDefault();
330
+ return;
331
+ }
332
+ onClick?.(event);
333
+ if (!event.defaultPrevented && isPlainClick(event)) closeNavigation();
334
+ },
335
+ children: /* @__PURE__ */ jsxs(Fragment$1, { children: [
336
+ /* @__PURE__ */ jsx("span", {
337
+ className: "pw-frame-nav-icon",
338
+ "aria-hidden": "true",
339
+ children: icon
340
+ }),
341
+ /* @__PURE__ */ jsx("span", {
342
+ className: expanded ? "pw-frame-nav-label" : "sr-only",
343
+ children: label
344
+ }),
345
+ expanded && badge != null && /* @__PURE__ */ jsx("span", {
346
+ className: "pw-frame-nav-badge",
347
+ "aria-hidden": "true",
348
+ children: badge
349
+ })
350
+ ] })
351
+ };
352
+ const item = target.href ? /* @__PURE__ */ jsx(AppLink, {
353
+ ...target,
354
+ ...props,
355
+ disabled
356
+ }) : /* @__PURE__ */ jsx("button", {
357
+ ...props,
358
+ type: "button",
359
+ disabled
360
+ });
361
+ return expanded ? item : /* @__PURE__ */ jsx(Tooltip, {
362
+ content: label,
363
+ side: "right",
364
+ delay: 0,
365
+ children: item
366
+ });
367
+ }
368
+ //#endregion
369
+ export { AppFrame, AppFrameNavItem, AppFrameToggle, useAppFrame };
@@ -12,10 +12,17 @@ export interface CellPlacement {
12
12
  className?: string | undefined;
13
13
  after?: React.ReactNode;
14
14
  stacked?: boolean | undefined;
15
+ /** A stacked value joined to the one before it on its line, after a dot. */
16
+ joined?: boolean | undefined;
15
17
  }
16
18
  /** A stacked column's value on the line under a row's name: it flows inline with the others and
17
19
  * wraps between them, never inside a value, which truncates if it is wider than the line. */
18
20
  export declare const STACKED_ITEM = "flex min-w-0 max-w-full items-center whitespace-nowrap [&>*]:min-w-0 [&>*]:truncate";
21
+ /** A joined stacked value's dot, part of the value so a value that renders nothing leaves none:
22
+ * 6px either side of it, where the line keeps 12px between values. */
23
+ export declare const STACKED_JOINED = "-ml-1.5 before:pr-1.5 before:content-['\u00B7'] before:text-(--theme-muted-text-color)";
24
+ /** Whether a cell's content shows anything: an empty string, like a missing value, doesn't. */
25
+ export declare function hasContent(children: React.ReactNode): boolean;
19
26
  /** Set by a list row around each of its cells; a `Table.Item` takes what it asks. */
20
27
  export declare const CellPlacementContext: React.Context<CellPlacement | null>;
21
28
  interface ItemProps extends React.TdHTMLAttributes<HTMLTableCellElement> {
@@ -26,13 +26,20 @@ var cellX = "px-3";
26
26
  /** A stacked column's value on the line under a row's name: it flows inline with the others and
27
27
  * wraps between them, never inside a value, which truncates if it is wider than the line. */
28
28
  var STACKED_ITEM = "flex min-w-0 max-w-full items-center whitespace-nowrap [&>*]:min-w-0 [&>*]:truncate";
29
+ /** A joined stacked value's dot, part of the value so a value that renders nothing leaves none:
30
+ * 6px either side of it, where the line keeps 12px between values. */
31
+ var STACKED_JOINED = "-ml-1.5 before:pr-1.5 before:content-['·'] before:text-(--theme-muted-text-color)";
32
+ /** Whether a cell's content shows anything: an empty string, like a missing value, doesn't. */
33
+ function hasContent(children) {
34
+ return React.Children.toArray(children).some((child) => child !== "");
35
+ }
29
36
  /** Set by a list row around each of its cells; a `Table.Item` takes what it asks. */
30
37
  var CellPlacementContext = createContext(null);
31
38
  function Item({ wrap = false, children, className, ...tdProps }) {
32
39
  const density = useContext(DensityContext);
33
40
  const placed = useContext(CellPlacementContext);
34
- if (placed?.stacked) return React.Children.toArray(children).length > 0 ? /* @__PURE__ */ jsx("div", {
35
- className: STACKED_ITEM,
41
+ if (placed?.stacked) return hasContent(children) ? /* @__PURE__ */ jsx("div", {
42
+ className: cx(STACKED_ITEM, placed.joined && "-ml-1.5 before:pr-1.5 before:content-['·'] before:text-(--theme-muted-text-color)"),
36
43
  "data-stacked": true,
37
44
  children
38
45
  }) : null;
@@ -121,4 +128,4 @@ function Table({ density = "comfortable", columns, caps = true, flush = false, c
121
128
  Table.Header = Header;
122
129
  Table.Item = Item;
123
130
  //#endregion
124
- export { CellPlacementContext, STACKED_ITEM, Table };
131
+ export { CellPlacementContext, STACKED_ITEM, STACKED_JOINED, Table, hasContent };
@@ -4,7 +4,7 @@ import { Tooltip } from "../components/Tooltip.js";
4
4
  import { useNavigation } from "../components/Provider.js";
5
5
  import { AppLink, isPlainClick } from "../components/AppLink.js";
6
6
  import { isMenuDivider, isMenuGroup, isMenuSubmenu, useRunAction, warnIfInert } from "../components/MenuEntries.js";
7
- import { CellPlacementContext, STACKED_ITEM, Table } from "../components/Table.js";
7
+ import { CellPlacementContext, STACKED_ITEM, Table, hasContent } from "../components/Table.js";
8
8
  import { ListCountText, ListFooterCountContext } from "./listSearch.js";
9
9
  import { ListFilterBar } from "./ListFilterBar.js";
10
10
  import { ListActionsHeader, ListError, ListSkeleton, StretchRowLinkContext } from "./ListTable.js";
@@ -19,20 +19,24 @@ var RowActionsContext = createContext({
19
19
  always: false,
20
20
  columnClasses: [],
21
21
  columnStacks: [],
22
+ columnStackLines: [],
22
23
  reservesLane: false
23
24
  });
25
+ var NO_STACK_LINES = [];
24
26
  function useRowActions(view) {
25
27
  return useMemo(() => ({
26
28
  show: view?.showActions ?? true,
27
29
  always: view?.alwaysShowActions ?? false,
28
30
  columnClasses: view?.columnClasses ?? [],
29
31
  columnStacks: view?.columnStacks ?? [],
32
+ columnStackLines: view?.columnStackLines ?? NO_STACK_LINES,
30
33
  reservesLane: view?.reservesActionLane ?? view?.lastColumnLocked ?? false
31
34
  }), [
32
35
  view?.showActions,
33
36
  view?.alwaysShowActions,
34
37
  view?.columnClasses,
35
38
  view?.columnStacks,
39
+ view?.columnStackLines,
36
40
  view?.lastColumnLocked,
37
41
  view?.reservesActionLane
38
42
  ]);
@@ -113,32 +117,67 @@ function ListFooter({ count, pager }) {
113
117
  });
114
118
  }
115
119
  var STACKED = { stacked: true };
116
- var STACKED_LINE = "mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 empty:hidden @[36rem]:hidden";
120
+ var STACKED_JOINED_PLACEMENT = {
121
+ stacked: true,
122
+ joined: true
123
+ };
124
+ var STACKED_LINE = "mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-(--theme-muted-text-color) empty:hidden @[36rem]:hidden";
125
+ var STACKED_LINES = "mt-1 flex min-w-0 flex-col gap-y-1 text-xs text-(--theme-muted-text-color) empty:hidden @[36rem]:hidden";
126
+ var STACKED_LINES_LINE = "flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1";
117
127
  var isCellElement = (child) => child.type === Table.Item || child.type === "td";
118
128
  function ListRow({ href, onActivate, items, goTo, openMenu, pinnedActions, selected, selectionActive, onRowSelect, leading, className, children }) {
119
129
  const { active: menuActive, openMenu: openRowMenu } = useRowMenuActive(openMenu);
120
- const { show: showActions, always: alwaysShowActions, columnClasses, columnStacks, reservesLane } = useContext(RowActionsContext);
130
+ const { show: showActions, always: alwaysShowActions, columnClasses, columnStacks, columnStackLines, reservesLane } = useContext(RowActionsContext);
121
131
  const cells = Children.toArray(children);
122
132
  const stacked = cells.flatMap((child, i) => {
123
133
  if (i === 0 || !columnStacks[i] || !isValidElement(child)) return [];
134
+ const line = columnStackLines[i];
124
135
  if (isCellElement(child)) {
125
136
  const content = child.props.children;
126
- return Children.toArray(content).length > 0 ? [child] : [];
137
+ return hasContent(content) ? [{
138
+ child,
139
+ line
140
+ }] : [];
127
141
  }
128
- return [child];
142
+ return [{
143
+ child,
144
+ line
145
+ }];
129
146
  });
130
- const under = stacked.length > 0 ? /* @__PURE__ */ jsx("div", {
147
+ const item = (child, j, joined = false) => isValidElement(child) && child.type === "td" ? /* @__PURE__ */ jsx("div", {
148
+ className: cx(STACKED_ITEM, joined && "-ml-1.5 before:pr-1.5 before:content-['·'] before:text-(--theme-muted-text-color)"),
149
+ "data-stacked": true,
150
+ children: child.props.children
151
+ }, j) : /* @__PURE__ */ jsx(CellPlacementContext, {
152
+ value: joined ? STACKED_JOINED_PLACEMENT : STACKED,
153
+ children: child
154
+ }, j);
155
+ let under;
156
+ if (stacked.length > 0 && stacked.every(({ line }) => line === void 0)) under = /* @__PURE__ */ jsx("div", {
131
157
  className: STACKED_LINE,
132
158
  "data-stacked-line": true,
133
- children: stacked.map((child, j) => isValidElement(child) && child.type === "td" ? /* @__PURE__ */ jsx("div", {
134
- className: STACKED_ITEM,
135
- "data-stacked": true,
136
- children: child.props.children
137
- }, j) : /* @__PURE__ */ jsx(CellPlacementContext, {
138
- value: STACKED,
139
- children: child
140
- }, j))
141
- }) : void 0;
159
+ children: stacked.map(({ child }, j) => item(child, j))
160
+ });
161
+ else if (stacked.length > 0) {
162
+ const lines = [];
163
+ stacked.forEach(({ child, line }, j) => {
164
+ const current = lines.at(-1);
165
+ if (!current || line === "own") {
166
+ lines.push([item(child, j)]);
167
+ return;
168
+ }
169
+ current.push(item(child, j, line === "join"));
170
+ });
171
+ under = /* @__PURE__ */ jsx("div", {
172
+ className: STACKED_LINES,
173
+ "data-stacked-lines": true,
174
+ children: lines.map((values, k) => /* @__PURE__ */ jsx("div", {
175
+ className: STACKED_LINES_LINE,
176
+ "data-stacked-line": true,
177
+ children: values
178
+ }, k))
179
+ });
180
+ }
142
181
  const styledCells = cells.map((child, i) => {
143
182
  const cls = cx(columnClasses[i], i > 0 && stacked.length > 0 && !columnStacks[i] && STAYS_BESIDE_STACK);
144
183
  const after = i === 0 ? under : void 0;
@@ -356,6 +395,9 @@ function shareWidths(columns, stacks) {
356
395
  const total = shares.reduce((sum, share) => sum + (share ?? 0), 0);
357
396
  const scale = (tier) => {
358
397
  const kept = columns.reduce((sum, col, i) => sum + (shows(col, i, tier) ? shares[i] ?? 0 : 0), 0);
398
+ const drops = columns.some((col, i) => !shows(col, i, tier));
399
+ const unshared = columns.some((col, i) => shows(col, i, tier) && shares[i] === void 0);
400
+ if (drops && !unshared && kept > 0) return 100 / kept;
359
401
  const left = 100 - (total - kept);
360
402
  return left > 0 ? 100 / left : 1;
361
403
  };
@@ -6,7 +6,7 @@ export { actionsColumnWidth, ListActionsHeader, ListCellText, ListError, ListSel
6
6
  export { ListToolbar, type ListToolbarProps } from './ListToolbar.js';
7
7
  export { ListDisplayMenu, ListFilterMenu } from './ListViewControls.js';
8
8
  export { ListCount, type ListSearch, ListSearchControl, useListSearch, } from './listSearch.js';
9
- export { type ActionDef, type ColumnDef, DEFAULT_ORDER, FAVORITE_ORDER, type FilterFacet, type ListView, type ListViewChrome, type ListViewConfig, type OrderByOption, useListView, } from './listView.js';
9
+ export { type ActionDef, type ColumnDef, DEFAULT_ORDER, FAVORITE_ORDER, type FilterFacet, type ListView, type ListViewChrome, type ListViewConfig, type OrderByOption, type StackLine, useListView, } from './listView.js';
10
10
  export { assembleRowMenu, type CopyFields, type CopySubmenu, MoreButton, type RowAction, rowContextMenuProps, useCopySubmenu, useRowMenuActive, } from './rowMenu.js';
11
11
  export { HoverCardRow, UserHoverCard, type UserHoverCardProps, } from './UserHoverCard.js';
12
12
  export { type FilterStore, type ListFilters, type ListFiltersConfig, useListFilters, } from './useListFilters.js';
@@ -26,6 +26,11 @@ export interface ColumnDef {
26
26
  * column there: for something short that compares best lined up, like a
27
27
  * total. The first column never stacks; it is what the others stack under. */
28
28
  stack?: boolean;
29
+ /** Where a stacked column's value sits on the lines under the name. By default the stacked
30
+ * values share a line, flowing and wrapping between them. `own` starts a line of its own, for
31
+ * a value read first, such as a status; `join` stays on the line before it after a "·", for
32
+ * one that belongs with that value, such as a job's nodes after its partition. */
33
+ stackLine?: StackLine | undefined;
29
34
  /** The `value` of the {@link OrderByOption} this column sorts by. Its header
30
35
  * then sorts the list: it shows, and sets, the same order as Display
31
36
  * options' Ordering. */
@@ -43,6 +48,9 @@ export interface ColumnDef {
43
48
  }
44
49
  /** Column responsive importance; `low` drops before `medium`. See {@link ColumnDef.priority}. */
45
50
  type ColumnPriority = 'low' | 'medium';
51
+ /** How a stacked column's value lines up with the others under the name; see
52
+ * {@link ColumnDef.stackLine}. */
53
+ export type StackLine = 'own' | 'join';
46
54
  /** Whether a column is always shown and kept out of the column picker. */
47
55
  export declare function isLocked(col: ColumnDef): boolean;
48
56
  interface GroupByOption {
@@ -130,6 +138,8 @@ export interface ListViewChrome {
130
138
  /** Per visible column, whether it stacks under the first on a narrow
131
139
  * container ({@link ColumnDef.stack}); `ListRow` reads entry `i`. */
132
140
  columnStacks: boolean[];
141
+ /** Per visible column, the line its stacked value takes ({@link ColumnDef.stackLine}). */
142
+ columnStackLines?: (StackLine | undefined)[] | undefined;
133
143
  /** Whether the last column is a locked one, such as a delete button's: the
134
144
  * row's `⋯` then keeps a lane of its own after it rather than floating over
135
145
  * it on hover. */
@@ -94,6 +94,7 @@ function useListView({ storageKey, sharedFiltersKey, columns, groupBys = [], def
94
94
  const visibleColumns = useMemo(() => columns.filter((c) => !c.inline && (isLocked(c) || !hiddenKeys.includes(c.key))), [columns, hiddenKeys]);
95
95
  const columnClasses = useMemo(() => visibleColumns.map(columnClass), [visibleColumns]);
96
96
  const columnStacks = useMemo(() => visibleColumns.map(stacks), [visibleColumns]);
97
+ const columnStackLines = useMemo(() => visibleColumns.map((c) => c.stackLine), [visibleColumns]);
97
98
  const last = visibleColumns.at(-1);
98
99
  const lastColumnLocked = visibleColumns.length > 1 && last !== void 0 && !last.alwaysVisible && isLocked(last);
99
100
  const reservesActionLane = lastColumnLocked || visibleColumns.length > 1 && last?.align === "end";
@@ -191,6 +192,7 @@ function useListView({ storageKey, sharedFiltersKey, columns, groupBys = [], def
191
192
  visibleColumns,
192
193
  columnClasses,
193
194
  columnStacks,
195
+ columnStackLines,
194
196
  lastColumnLocked,
195
197
  reservesActionLane,
196
198
  isVisible,