@parallelworks/ui 0.92.0 → 0.94.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 +82 -1
- package/dist/app-frame/app-frame.css +281 -0
- package/dist/app-frame/index.d.ts +73 -0
- package/dist/app-frame/index.js +369 -0
- package/dist/components/PageHeader.js +70 -34
- package/dist/components/Provider.d.ts +2 -0
- package/dist/components/Provider.js +2 -1
- package/dist/components/headerActionsFit.d.ts +15 -0
- package/dist/components/headerActionsFit.js +36 -3
- package/dist/list/ListPage.js +13 -9
- package/dist/list/listSearch.d.ts +8 -1
- package/dist/list/listSearch.js +151 -55
- package/package.json +8 -3
|
@@ -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 };
|
|
@@ -151,47 +151,62 @@ function HeaderLayout({ breadcrumbs, heading, titleActions, actions, toolbar, fi
|
|
|
151
151
|
const [row, setRow] = useState(null);
|
|
152
152
|
const [lead, setLead] = useState(null);
|
|
153
153
|
const [end, setEnd] = useState(null);
|
|
154
|
+
const [cover, setCover] = useState(null);
|
|
154
155
|
useActionsFit(fit, row, lead, end);
|
|
156
|
+
useLayoutEffect(() => {
|
|
157
|
+
fit.setCoverNode(cover);
|
|
158
|
+
return () => fit.setCoverNode(null);
|
|
159
|
+
}, [fit, cover]);
|
|
155
160
|
useSyncExternalStore(fit.subscribe, fit.getVersion, fit.getVersion);
|
|
156
161
|
const moved = fit.menu();
|
|
157
162
|
const hasTrail = breadcrumbs.length > 0;
|
|
163
|
+
const covered = fit.isCovered();
|
|
158
164
|
return /* @__PURE__ */ jsxs("div", {
|
|
159
165
|
className: cx("@container w-full shrink-0", !inPlace && "bg-(--theme-app-bg)", className),
|
|
160
166
|
children: [/* @__PURE__ */ jsxs("div", {
|
|
161
167
|
ref: setRow,
|
|
162
|
-
className: cx("flex items-center justify-between gap-4 border-b border-(--theme-border) px-4", compact ? "h-8" : "h-12"),
|
|
163
|
-
children: [
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
168
|
+
className: cx("relative flex items-center justify-between gap-4 border-b border-(--theme-border) px-4", compact ? "h-8" : "h-12"),
|
|
169
|
+
children: [
|
|
170
|
+
/* @__PURE__ */ jsxs("div", {
|
|
171
|
+
ref: setLead,
|
|
172
|
+
inert: covered,
|
|
173
|
+
className: cx("flex min-w-0 flex-1 items-center gap-2", covered && "invisible"),
|
|
174
|
+
children: [
|
|
175
|
+
navToggle?.(),
|
|
176
|
+
hasTrail && /* @__PURE__ */ jsx(Breadcrumbs, {
|
|
177
|
+
crumbs: breadcrumbs,
|
|
178
|
+
heading
|
|
179
|
+
}),
|
|
180
|
+
present(titleActions) && /* @__PURE__ */ jsx("div", {
|
|
181
|
+
className: "flex shrink-0 items-center gap-0.5",
|
|
182
|
+
children: titleActions
|
|
183
|
+
}),
|
|
184
|
+
hasTrail && breadcrumbPin?.({ crumbs: breadcrumbs })
|
|
185
|
+
]
|
|
186
|
+
}),
|
|
187
|
+
present(actions) && /* @__PURE__ */ jsxs("div", {
|
|
188
|
+
ref: setEnd,
|
|
189
|
+
inert: covered,
|
|
190
|
+
className: cx("flex shrink-0 items-center gap-1", covered && "invisible"),
|
|
191
|
+
children: [/* @__PURE__ */ jsx(HeaderActionsFitContext, {
|
|
192
|
+
value: fit,
|
|
193
|
+
children: actions
|
|
194
|
+
}), moved.length > 0 && /* @__PURE__ */ jsx(ActionMenu, {
|
|
195
|
+
items: moved,
|
|
196
|
+
label: t.moreActions,
|
|
197
|
+
triggerClassName: buttonClasses({
|
|
198
|
+
variant: "ghost",
|
|
199
|
+
size: "xs",
|
|
200
|
+
iconOnly: true
|
|
201
|
+
}),
|
|
202
|
+
children: /* @__PURE__ */ jsx(MoreIcon, {})
|
|
203
|
+
})]
|
|
204
|
+
}),
|
|
205
|
+
/* @__PURE__ */ jsx("div", {
|
|
206
|
+
ref: setCover,
|
|
207
|
+
className: "pointer-events-none absolute inset-0 flex items-center px-4"
|
|
208
|
+
})
|
|
209
|
+
]
|
|
195
210
|
}), present(toolbar) && /* @__PURE__ */ jsx("div", {
|
|
196
211
|
className: "px-4",
|
|
197
212
|
children: toolbar
|
|
@@ -243,10 +258,13 @@ function useActionsFit(fit, row, lead, end) {
|
|
|
243
258
|
}
|
|
244
259
|
leadMin += Math.max(0, leadItems - 1) * LEAD_GAP;
|
|
245
260
|
const room = rowWidth - Number.parseFloat(style.paddingLeft || "0") - Number.parseFloat(style.paddingRight || "0") - leadMin - ROW_GAP;
|
|
261
|
+
const shown = end.getBoundingClientRect().width;
|
|
262
|
+
const moved = items.filter((id) => fit.isOverflowed(id));
|
|
246
263
|
fit.setOverflow(overflowingActions(items.map((id) => ({
|
|
247
264
|
id,
|
|
248
265
|
width: width(id)
|
|
249
|
-
})),
|
|
266
|
+
})), shown, moved, room));
|
|
267
|
+
fit.setSearchCovers(searchCovers(end, items, moved, width, shown, room));
|
|
250
268
|
};
|
|
251
269
|
measure();
|
|
252
270
|
const resize = new ResizeObserver(measure);
|
|
@@ -270,5 +288,23 @@ function useActionsFit(fit, row, lead, end) {
|
|
|
270
288
|
widths
|
|
271
289
|
]);
|
|
272
290
|
}
|
|
291
|
+
var SEARCH_FIELD_WIDTH = 288;
|
|
292
|
+
/**
|
|
293
|
+
* Whether a search in the actions, opened in place, would leave the trail too little room even
|
|
294
|
+
* with every action in the "…" menu, so it takes the whole bar instead. It works from the
|
|
295
|
+
* actions' width as they are now, whether the search shows as its button or its field, so the
|
|
296
|
+
* answer is the same either way.
|
|
297
|
+
*/
|
|
298
|
+
function searchCovers(end, items, moved, width, shown, room) {
|
|
299
|
+
const control = end.querySelector("[data-header-search]")?.firstElementChild;
|
|
300
|
+
if (!control) return false;
|
|
301
|
+
let least = shown - control.getBoundingClientRect().width + SEARCH_FIELD_WIDTH;
|
|
302
|
+
const still = items.filter((id) => !moved.includes(id));
|
|
303
|
+
if (still.length > 0) {
|
|
304
|
+
least -= still.reduce((sum, id) => sum + width(id) + 4, 0);
|
|
305
|
+
if (moved.length === 0) least += 28;
|
|
306
|
+
}
|
|
307
|
+
return least > room + .5;
|
|
308
|
+
}
|
|
273
309
|
//#endregion
|
|
274
310
|
export { PageHeader, PageHeaderBar, PageHeaderProvider };
|
|
@@ -653,6 +653,8 @@ export interface UIStrings {
|
|
|
653
653
|
/** Where the page sits, for `Pagination` too narrow for page numbers: it shows "5 / 12". */
|
|
654
654
|
pageOfTotal: (page: number, total: number) => string;
|
|
655
655
|
removeSearchTerm: (term: string) => string;
|
|
656
|
+
/** The search button's name while a query it holds narrows the list, its field closed. */
|
|
657
|
+
searchActive: (label: string) => string;
|
|
656
658
|
};
|
|
657
659
|
/** The chip row of a list's active filters, its Filter menu and its pickers. Field and option
|
|
658
660
|
* labels come from the host's fields, already translated. */
|
|
@@ -634,7 +634,8 @@ var DEFAULTS = {
|
|
|
634
634
|
pagination: "Pagination",
|
|
635
635
|
page: (page) => `Page ${page}`,
|
|
636
636
|
pageOfTotal: (page, total) => `Page ${page} of ${total}`,
|
|
637
|
-
removeSearchTerm: (term) => `Remove filter ${term}
|
|
637
|
+
removeSearchTerm: (term) => `Remove filter ${term}`,
|
|
638
|
+
searchActive: (label) => `${label}, on`
|
|
638
639
|
},
|
|
639
640
|
filters: {
|
|
640
641
|
addFilter: "Filter",
|
|
@@ -9,6 +9,9 @@ import type { MenuEntry } from './MenuEntries.js';
|
|
|
9
9
|
export declare class HeaderActionsFit {
|
|
10
10
|
private entries;
|
|
11
11
|
private overflow;
|
|
12
|
+
private covers;
|
|
13
|
+
private cover;
|
|
14
|
+
private coverers;
|
|
12
15
|
private version;
|
|
13
16
|
private listeners;
|
|
14
17
|
subscribe: (listener: () => void) => (() => void);
|
|
@@ -20,11 +23,23 @@ export declare class HeaderActionsFit {
|
|
|
20
23
|
/** The moved actions' rows, in the bar's order. */
|
|
21
24
|
menu(): MenuEntry[];
|
|
22
25
|
setOverflow(ids: readonly string[]): void;
|
|
26
|
+
/** Whether an open search would take the whole bar: its field, opened in place, wouldn't fit
|
|
27
|
+
* beside the trail even with every action in the "…" menu. The bar measures it. */
|
|
28
|
+
searchCovers(): boolean;
|
|
29
|
+
setSearchCovers(covers: boolean): void;
|
|
30
|
+
/** Where a search that takes the bar renders its field: a layer over the bar's row. */
|
|
31
|
+
coverNode(): HTMLElement | null;
|
|
32
|
+
setCoverNode(node: HTMLElement | null): void;
|
|
33
|
+
/** Whether a search has the bar now, so the bar hides and disables what it covers. */
|
|
34
|
+
isCovered(): boolean;
|
|
35
|
+
setCovering(id: string, covering: boolean): void;
|
|
23
36
|
private changed;
|
|
24
37
|
}
|
|
25
38
|
/** Set around the header's `actions`, so the actions there can move into its
|
|
26
39
|
* menu; title actions sit beside the title and never do. */
|
|
27
40
|
export declare const HeaderActionsFitContext: import("react").Context<HeaderActionsFit | null>;
|
|
41
|
+
export declare const ACTION_GAP = 4;
|
|
42
|
+
export declare const MENU_BUTTON_WIDTH = 24;
|
|
28
43
|
/**
|
|
29
44
|
* The trailing actions to move into the "…" menu so the rest fit in `room`
|
|
30
45
|
* pixels, last first, or none when everything fits. `shown` is the actions'
|
|
@@ -10,6 +10,9 @@ import { createContext } from "react";
|
|
|
10
10
|
var HeaderActionsFit = class {
|
|
11
11
|
entries = /* @__PURE__ */ new Map();
|
|
12
12
|
overflow = [];
|
|
13
|
+
covers = false;
|
|
14
|
+
cover = null;
|
|
15
|
+
coverers = /* @__PURE__ */ new Set();
|
|
13
16
|
version = 0;
|
|
14
17
|
listeners = /* @__PURE__ */ new Set();
|
|
15
18
|
subscribe = (listener) => {
|
|
@@ -46,6 +49,37 @@ var HeaderActionsFit = class {
|
|
|
46
49
|
this.overflow = ids;
|
|
47
50
|
this.changed();
|
|
48
51
|
}
|
|
52
|
+
/** Whether an open search would take the whole bar: its field, opened in place, wouldn't fit
|
|
53
|
+
* beside the trail even with every action in the "…" menu. The bar measures it. */
|
|
54
|
+
searchCovers() {
|
|
55
|
+
return this.covers;
|
|
56
|
+
}
|
|
57
|
+
setSearchCovers(covers) {
|
|
58
|
+
if (covers !== this.covers) {
|
|
59
|
+
this.covers = covers;
|
|
60
|
+
this.changed();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Where a search that takes the bar renders its field: a layer over the bar's row. */
|
|
64
|
+
coverNode() {
|
|
65
|
+
return this.cover;
|
|
66
|
+
}
|
|
67
|
+
setCoverNode(node) {
|
|
68
|
+
if (node !== this.cover) {
|
|
69
|
+
this.cover = node;
|
|
70
|
+
this.changed();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Whether a search has the bar now, so the bar hides and disables what it covers. */
|
|
74
|
+
isCovered() {
|
|
75
|
+
return this.coverers.size > 0;
|
|
76
|
+
}
|
|
77
|
+
setCovering(id, covering) {
|
|
78
|
+
if (covering === this.coverers.has(id)) return;
|
|
79
|
+
if (covering) this.coverers.add(id);
|
|
80
|
+
else this.coverers.delete(id);
|
|
81
|
+
this.changed();
|
|
82
|
+
}
|
|
49
83
|
changed() {
|
|
50
84
|
this.version++;
|
|
51
85
|
for (const listener of this.listeners) listener();
|
|
@@ -54,7 +88,6 @@ var HeaderActionsFit = class {
|
|
|
54
88
|
/** Set around the header's `actions`, so the actions there can move into its
|
|
55
89
|
* menu; title actions sit beside the title and never do. */
|
|
56
90
|
var HeaderActionsFitContext = createContext(null);
|
|
57
|
-
var ACTION_GAP = 4;
|
|
58
91
|
/**
|
|
59
92
|
* The trailing actions to move into the "…" menu so the rest fit in `room`
|
|
60
93
|
* pixels, last first, or none when everything fits. `shown` is the actions'
|
|
@@ -65,14 +98,14 @@ function overflowingActions(items, shown, moved, room) {
|
|
|
65
98
|
const width = new Map(items.map((item) => [item.id, item.width]));
|
|
66
99
|
let total = shown;
|
|
67
100
|
if (moved.length > 0) {
|
|
68
|
-
total += moved.reduce((sum, id) => sum + (width.get(id) ?? 0) +
|
|
101
|
+
total += moved.reduce((sum, id) => sum + (width.get(id) ?? 0) + 4, 0);
|
|
69
102
|
total -= 28;
|
|
70
103
|
}
|
|
71
104
|
const next = [];
|
|
72
105
|
for (let i = items.length - 1; i >= 0 && total > room + .5; i--) {
|
|
73
106
|
const item = items[i];
|
|
74
107
|
if (next.length === 0) total += 28;
|
|
75
|
-
total -= item.width +
|
|
108
|
+
total -= item.width + 4;
|
|
76
109
|
next.unshift(item.id);
|
|
77
110
|
}
|
|
78
111
|
return next;
|
package/dist/list/ListPage.js
CHANGED
|
@@ -5,7 +5,7 @@ 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
7
|
import { CellPlacementContext, STACKED_ITEM, Table, hasContent } from "../components/Table.js";
|
|
8
|
-
import {
|
|
8
|
+
import { ListCountStatus, ListFooterCountContext } from "./listSearch.js";
|
|
9
9
|
import { ListFilterBar } from "./ListFilterBar.js";
|
|
10
10
|
import { ListActionsHeader, ListError, ListSkeleton, StretchRowLinkContext } from "./ListTable.js";
|
|
11
11
|
import { ListToolbar, toolbarCount } from "./ListToolbar.js";
|
|
@@ -69,7 +69,8 @@ function ListPage({ bleed = false, toolbar, filterBar, view, loading = false, er
|
|
|
69
69
|
nounPlural: count.nounPlural
|
|
70
70
|
} : fromToolbar;
|
|
71
71
|
const showsRows = !loading && !failed && !isEmpty && !noneMatch && (!counted || counted.matches > 0);
|
|
72
|
-
const
|
|
72
|
+
const countable = !!count || fromToolbar !== void 0;
|
|
73
|
+
const spokenCount = loading || failed ? void 0 : counted;
|
|
73
74
|
const shareLine = counted !== void 0 && !!toolbar && isValidElement(givenBar) && givenBar.type === ListFilterBar;
|
|
74
75
|
const bar = shareLine ? cloneElement(givenBar, { end: toolbar }) : givenBar;
|
|
75
76
|
let body = children;
|
|
@@ -102,18 +103,21 @@ function ListPage({ bleed = false, toolbar, filterBar, view, loading = false, er
|
|
|
102
103
|
children: [/* @__PURE__ */ jsx(ListRowActionsProvider, {
|
|
103
104
|
view,
|
|
104
105
|
children: body
|
|
105
|
-
}), (
|
|
106
|
-
count:
|
|
107
|
-
|
|
106
|
+
}), (countable || showsRows && !!pager) && /* @__PURE__ */ jsx(ListFooter, {
|
|
107
|
+
count: spokenCount,
|
|
108
|
+
countable,
|
|
109
|
+
pager: showsRows ? pager : null,
|
|
110
|
+
shown: showsRows && (counted !== void 0 || !!pager)
|
|
108
111
|
})]
|
|
109
112
|
})]
|
|
110
113
|
});
|
|
111
114
|
}
|
|
112
|
-
/** The line under a list's last row: its count at the rows' start, its pager at their end.
|
|
113
|
-
|
|
115
|
+
/** The line under a list's last row: its count at the rows' start, its pager at their end. With no
|
|
116
|
+
* rows to show, only a screen reader finds it, so the count's status stays mounted. */
|
|
117
|
+
function ListFooter({ count, countable, pager, shown }) {
|
|
114
118
|
return /* @__PURE__ */ jsxs("div", {
|
|
115
|
-
className: "flex min-h-10 flex-wrap items-center gap-x-3 px-4 empty:hidden [&>:not([role=status])]:ml-auto [&>nav]:flex-1 [&>nav]:px-0 [&>nav>div:first-child]:justify-end",
|
|
116
|
-
children: [
|
|
119
|
+
className: shown ? "flex min-h-10 flex-wrap items-center gap-x-3 px-4 empty:hidden [&>:not([role=status])]:ml-auto [&>nav]:flex-1 [&>nav]:px-0 [&>nav>div:first-child]:justify-end" : "sr-only",
|
|
120
|
+
children: [countable && /* @__PURE__ */ jsx(ListCountStatus, { count }), pager]
|
|
117
121
|
});
|
|
118
122
|
}
|
|
119
123
|
var STACKED = { stacked: true };
|