@parallelworks/ui 0.93.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 +8 -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 +1 -1
package/README.md
CHANGED
|
@@ -228,6 +228,14 @@ A page declares its header in one place, and the shell shows it:
|
|
|
228
228
|
such as a log's follow and refresh: its buttons are outlined, and `primary`
|
|
229
229
|
fills the one, if any, the panel leads to.
|
|
230
230
|
**`toolbar`** is a row under the bar, such as tabs or a time range.
|
|
231
|
+
- A list's controls may sit in **`actions`**: a `ListToolbar` there, with its
|
|
232
|
+
filter, display options and search, leaves the list's count under its rows.
|
|
233
|
+
Where its search field, opened, wouldn't fit beside the trail, as on a phone,
|
|
234
|
+
the open search takes the whole bar until it closes; the rest of the bar
|
|
235
|
+
stays as it was underneath, hidden and `inert`, and focus returns to the
|
|
236
|
+
search button. While a search term narrows the list with the field closed,
|
|
237
|
+
the button shows a dot and its name says so (`searchActive`). Nothing to set
|
|
238
|
+
up: the bar measures it.
|
|
231
239
|
- **`hidden`** takes the bar away while the page is mounted, for a page that
|
|
232
240
|
fills the window.
|
|
233
241
|
|
|
@@ -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 };
|
|
@@ -35,7 +35,14 @@ export declare const ListFooterCountContext: import("react").Context<boolean>;
|
|
|
35
35
|
export declare const ListToolbarInlineContext: import("react").Context<boolean>;
|
|
36
36
|
/** The count's words, "7 tokens" or "2 of 7 tokens", as a status so a screen reader hears the
|
|
37
37
|
* count change as a filter narrows it. */
|
|
38
|
-
export declare function ListCountText(
|
|
38
|
+
export declare function ListCountText(props: ListCountProps): import("react").JSX.Element;
|
|
39
|
+
/** A list's count as a status that can say nothing yet: kept mounted while its text is empty, so
|
|
40
|
+
* a screen reader hears the count when it arrives, as it would not from a status that mounts
|
|
41
|
+
* with its text already in. */
|
|
42
|
+
export declare function ListCountStatus({ count, className, }: {
|
|
43
|
+
count: ListCountProps | undefined;
|
|
44
|
+
className?: string | undefined;
|
|
45
|
+
}): import("react").JSX.Element;
|
|
39
46
|
/** A list's count, placed by hand.
|
|
40
47
|
* @deprecated Give `ListPage` a `count` instead (with its `total` and `matches`): it shows the
|
|
41
48
|
* count under the last row, beside a `pager`, and hides it while the list shows its empty or
|
package/dist/list/listSearch.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { cx } from "../cx.js";
|
|
1
2
|
import { buttonClasses } from "../components/buttonClasses.js";
|
|
2
3
|
import { CloseIcon, RemoveIcon, SearchIcon } from "../icons.js";
|
|
3
4
|
import { ariaKeyShortcuts } from "../components/Kbd.js";
|
|
4
5
|
import { Tooltip } from "../components/Tooltip.js";
|
|
5
6
|
import { useLocale, usePersistedState, useStrings } from "../components/Provider.js";
|
|
6
7
|
import { isTextEntryContext } from "../components/shortcutKey.js";
|
|
8
|
+
import { HeaderActionsFitContext } from "../components/headerActionsFit.js";
|
|
7
9
|
import { formatInteger } from "../format.js";
|
|
8
|
-
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
|
9
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
import { createContext, use, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
11
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
12
|
+
import { createPortal } from "react-dom";
|
|
10
13
|
//#region src/list/listSearch.tsx
|
|
11
14
|
var NO_TERMS = [];
|
|
12
15
|
/** Pass `enabled: false` to suspend the global ⌘F/`/` hijack (e.g. when search isn't shown and browser Find should stay active). */
|
|
@@ -69,67 +72,149 @@ function useListSearch(enabled = true, storageKey) {
|
|
|
69
72
|
function ListSearchControl({ search, placeholder, label: labelProp }) {
|
|
70
73
|
const { common, list } = useStrings();
|
|
71
74
|
const label = labelProp ?? common.search;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
75
|
+
const fit = use(HeaderActionsFitContext);
|
|
76
|
+
useSyncExternalStore(fit?.subscribe ?? subscribeNever, fit?.getVersion ?? zero, zero);
|
|
77
|
+
const covers = fit?.searchCovers() ?? false;
|
|
78
|
+
const coverNode = fit?.coverNode() ?? null;
|
|
79
|
+
const covering = covers && search.searchOpen && coverNode !== null;
|
|
80
|
+
const field = covering || !covers && (search.searchOpen || search.terms.length > 0);
|
|
81
|
+
const active = search.terms.length > 0 || search.filter.trim() !== "";
|
|
82
|
+
const id = useId();
|
|
83
|
+
useLayoutEffect(() => {
|
|
84
|
+
fit?.setCovering(id, covering);
|
|
85
|
+
}, [
|
|
86
|
+
fit,
|
|
87
|
+
id,
|
|
88
|
+
covering
|
|
89
|
+
]);
|
|
90
|
+
useLayoutEffect(() => fit ? () => fit.setCovering(id, false) : void 0, [fit, id]);
|
|
91
|
+
const button = useRef(null);
|
|
92
|
+
const returnFocus = useRef(false);
|
|
93
|
+
useLayoutEffect(() => {
|
|
94
|
+
if (!field && returnFocus.current) {
|
|
95
|
+
returnFocus.current = false;
|
|
96
|
+
queueMicrotask(() => button.current?.focus());
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
const { inputRef, searchOpen } = search;
|
|
100
|
+
const wasCovering = useRef(covering);
|
|
101
|
+
useLayoutEffect(() => {
|
|
102
|
+
if (wasCovering.current === covering) return;
|
|
103
|
+
wasCovering.current = covering;
|
|
104
|
+
if (!searchOpen) return;
|
|
105
|
+
queueMicrotask(() => {
|
|
106
|
+
const focused = document.activeElement;
|
|
107
|
+
if (!focused || focused === document.body || coverNode?.parentElement?.contains(focused)) inputRef.current?.focus();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
if (field) {
|
|
111
|
+
const close = () => {
|
|
112
|
+
returnFocus.current = true;
|
|
113
|
+
search.clearSearch();
|
|
114
|
+
};
|
|
115
|
+
const closeOnEscape = (e) => {
|
|
116
|
+
if (e.key === "Escape" && search.searchOpen) {
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
returnFocus.current = covering || search.terms.length === 0;
|
|
119
|
+
search.closeSearch();
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
};
|
|
124
|
+
const box = /* @__PURE__ */ jsxs("div", {
|
|
125
|
+
className: cx("flex flex-wrap items-center gap-1.5 rounded-md border border-(--theme-border) bg-(--theme-muted-panel-bg) px-2 py-0.5", covering ? "pointer-events-auto min-h-8 w-full" : "min-h-7 w-72 max-w-[55vw]"),
|
|
126
|
+
children: [
|
|
127
|
+
/* @__PURE__ */ jsx(SearchIcon, { className: "w-3.5 h-3.5 shrink-0 text-(--theme-muted-text-color)" }),
|
|
128
|
+
search.terms.map((term) => /* @__PURE__ */ jsxs("span", {
|
|
129
|
+
className: "flex max-w-40 items-center gap-1 rounded border border-(--theme-border) bg-(--theme-app-bg) px-1.5 py-px text-[12px] text-(--theme-app)",
|
|
130
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
131
|
+
className: "truncate",
|
|
132
|
+
children: term
|
|
133
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
134
|
+
type: "button",
|
|
135
|
+
onClick: () => search.removeTerm(term),
|
|
136
|
+
"aria-label": list.removeSearchTerm(term),
|
|
137
|
+
className: "shrink-0 rounded p-0.5 text-(--theme-muted-text-color) hover:text-(--theme-app) transition-colors",
|
|
138
|
+
children: /* @__PURE__ */ jsx(RemoveIcon, { className: "w-2.5 h-2.5" })
|
|
139
|
+
})]
|
|
140
|
+
}, term)),
|
|
141
|
+
/* @__PURE__ */ jsx("input", {
|
|
142
|
+
ref: search.inputRef,
|
|
143
|
+
value: search.filter,
|
|
144
|
+
onChange: (e) => search.setFilter(e.target.value),
|
|
145
|
+
onFocus: search.openSearch,
|
|
146
|
+
"aria-label": label,
|
|
147
|
+
onKeyDown: (e) => {
|
|
148
|
+
if (closeOnEscape(e)) return;
|
|
149
|
+
if (e.key === "Enter") {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
search.addTerm(search.filter);
|
|
152
|
+
} else if (e.key === "Backspace" && !search.filter) {
|
|
153
|
+
const lastTerm = search.terms.at(-1);
|
|
154
|
+
if (lastTerm) search.removeTerm(lastTerm);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
placeholder,
|
|
158
|
+
className: "min-w-24 flex-1 bg-transparent text-[13px] text-(--theme-app) placeholder:text-(--theme-placeholder) focus:outline-none"
|
|
159
|
+
}),
|
|
160
|
+
/* @__PURE__ */ jsx("button", {
|
|
82
161
|
type: "button",
|
|
83
|
-
onClick:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
} else if (e.key === "Enter") {
|
|
100
|
-
e.preventDefault();
|
|
101
|
-
search.addTerm(search.filter);
|
|
102
|
-
} else if (e.key === "Backspace" && !search.filter) {
|
|
103
|
-
const lastTerm = search.terms.at(-1);
|
|
104
|
-
if (lastTerm) search.removeTerm(lastTerm);
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
placeholder,
|
|
108
|
-
className: "min-w-24 flex-1 bg-transparent text-[13px] text-(--theme-app) placeholder:text-(--theme-placeholder) focus:outline-none"
|
|
109
|
-
}),
|
|
110
|
-
/* @__PURE__ */ jsx("button", {
|
|
111
|
-
type: "button",
|
|
112
|
-
onClick: search.clearSearch,
|
|
113
|
-
"aria-label": common.clearSearch,
|
|
114
|
-
className: "shrink-0 rounded p-0.5 text-(--theme-muted-text-color) hover:text-(--theme-app) transition-colors",
|
|
115
|
-
children: /* @__PURE__ */ jsx(CloseIcon, { className: "w-3 h-3" })
|
|
162
|
+
onClick: close,
|
|
163
|
+
onKeyDown: closeOnEscape,
|
|
164
|
+
"aria-label": common.clearSearch,
|
|
165
|
+
className: cx("shrink-0 rounded text-(--theme-muted-text-color) hover:text-(--theme-app) transition-colors", covering ? "-me-1 inline-flex size-6 items-center justify-center" : "p-0.5"),
|
|
166
|
+
children: /* @__PURE__ */ jsx(CloseIcon, { className: "w-3 h-3" })
|
|
167
|
+
})
|
|
168
|
+
]
|
|
169
|
+
});
|
|
170
|
+
if (covering && coverNode) return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
|
|
171
|
+
"data-header-search": "",
|
|
172
|
+
className: "contents",
|
|
173
|
+
children: /* @__PURE__ */ jsx(SearchButton, {
|
|
174
|
+
label,
|
|
175
|
+
name: active ? list.searchActive(label) : label,
|
|
176
|
+
active,
|
|
177
|
+
onOpen: search.openSearch
|
|
116
178
|
})
|
|
117
|
-
]
|
|
179
|
+
}), createPortal(box, coverNode)] });
|
|
180
|
+
return fit ? /* @__PURE__ */ jsx("span", {
|
|
181
|
+
"data-header-search": "",
|
|
182
|
+
className: "contents",
|
|
183
|
+
children: box
|
|
184
|
+
}) : box;
|
|
185
|
+
}
|
|
186
|
+
const control = /* @__PURE__ */ jsx(SearchButton, {
|
|
187
|
+
ref: button,
|
|
188
|
+
label,
|
|
189
|
+
name: active ? list.searchActive(label) : label,
|
|
190
|
+
active,
|
|
191
|
+
onOpen: search.openSearch
|
|
118
192
|
});
|
|
193
|
+
return fit ? /* @__PURE__ */ jsx("span", {
|
|
194
|
+
"data-header-search": "",
|
|
195
|
+
className: "contents",
|
|
196
|
+
children: control
|
|
197
|
+
}) : control;
|
|
198
|
+
}
|
|
199
|
+
var subscribeNever = () => () => {};
|
|
200
|
+
var zero = () => 0;
|
|
201
|
+
/** The search closed: a button, with a dot while a query it holds narrows the list. */
|
|
202
|
+
function SearchButton({ ref, label, name = label, active, onOpen }) {
|
|
119
203
|
return /* @__PURE__ */ jsx(Tooltip, {
|
|
120
204
|
content: label,
|
|
121
205
|
shortcut: "/",
|
|
122
|
-
children: /* @__PURE__ */
|
|
206
|
+
children: /* @__PURE__ */ jsxs("button", {
|
|
207
|
+
ref,
|
|
123
208
|
type: "button",
|
|
124
|
-
onClick:
|
|
125
|
-
"aria-label":
|
|
209
|
+
onClick: onOpen,
|
|
210
|
+
"aria-label": name,
|
|
126
211
|
"aria-keyshortcuts": `/ ${ariaKeyShortcuts("Mod+F")}`,
|
|
127
212
|
className: buttonClasses({
|
|
128
213
|
variant: "outline",
|
|
129
214
|
size: "sm",
|
|
130
215
|
iconOnly: true
|
|
131
216
|
}),
|
|
132
|
-
children: /* @__PURE__ */ jsx(SearchIcon, {})
|
|
217
|
+
children: [/* @__PURE__ */ jsx(SearchIcon, {}), active && /* @__PURE__ */ jsx("span", { className: "absolute right-0 top-0 h-1.5 w-1.5 rounded-full bg-(--theme-link)" })]
|
|
133
218
|
})
|
|
134
219
|
});
|
|
135
220
|
}
|
|
@@ -141,14 +226,25 @@ var ListFooterCountContext = createContext(false);
|
|
|
141
226
|
var ListToolbarInlineContext = createContext(false);
|
|
142
227
|
/** The count's words, "7 tokens" or "2 of 7 tokens", as a status so a screen reader hears the
|
|
143
228
|
* count change as a filter narrows it. */
|
|
144
|
-
function ListCountText(
|
|
229
|
+
function ListCountText(props) {
|
|
230
|
+
return /* @__PURE__ */ jsx(ListCountStatus, { count: props });
|
|
231
|
+
}
|
|
232
|
+
/** A list's count as a status that can say nothing yet: kept mounted while its text is empty, so
|
|
233
|
+
* a screen reader hears the count when it arrives, as it would not from a status that mounts
|
|
234
|
+
* with its text already in. */
|
|
235
|
+
function ListCountStatus({ count, className = "text-[13px] text-(--theme-muted-text-color)" }) {
|
|
145
236
|
const { list: t } = useStrings();
|
|
146
237
|
const locale = useLocale();
|
|
147
|
-
const
|
|
238
|
+
const n = (value) => formatInteger(value, locale);
|
|
239
|
+
let words = "";
|
|
240
|
+
if (count) {
|
|
241
|
+
const { total, matches, filterActive, noun, nounPlural } = count;
|
|
242
|
+
words = filterActive ? t.matchCount(n(matches), n(total), nounPlural) : t.count(n(total), total === 1 ? noun : nounPlural);
|
|
243
|
+
}
|
|
148
244
|
return /* @__PURE__ */ jsx("span", {
|
|
149
245
|
role: "status",
|
|
150
|
-
className
|
|
151
|
-
children:
|
|
246
|
+
className,
|
|
247
|
+
children: words
|
|
152
248
|
});
|
|
153
249
|
}
|
|
154
250
|
/** A list's count, placed by hand.
|
|
@@ -163,4 +259,4 @@ function ListCount(props) {
|
|
|
163
259
|
});
|
|
164
260
|
}
|
|
165
261
|
//#endregion
|
|
166
|
-
export { ListCount, ListCountText, ListFooterCountContext, ListSearchControl, ListToolbarInlineContext, useListSearch };
|
|
262
|
+
export { ListCount, ListCountStatus, ListCountText, ListFooterCountContext, ListSearchControl, ListToolbarInlineContext, useListSearch };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parallelworks/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.94.0",
|
|
4
4
|
"description": "React UI components: a theme contract, icons, brand logos, primitives, lists, forms, a job graph, a code editor, a log viewer, a file explorer and an AI chat, each on its own subpath",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|