@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.
@@ -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({ total, matches, filterActive, noun, nounPlural }: ListCountProps): import("react").JSX.Element;
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
@@ -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
- if (search.searchOpen || search.terms.length > 0) return /* @__PURE__ */ jsxs("div", {
73
- className: "flex min-h-7 w-72 max-w-[55vw] flex-wrap items-center gap-1.5 rounded-md border border-(--theme-border) bg-(--theme-muted-panel-bg) px-2 py-0.5",
74
- children: [
75
- /* @__PURE__ */ jsx(SearchIcon, { className: "w-3.5 h-3.5 shrink-0 text-(--theme-muted-text-color)" }),
76
- search.terms.map((term) => /* @__PURE__ */ jsxs("span", {
77
- 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)",
78
- children: [/* @__PURE__ */ jsx("span", {
79
- className: "truncate",
80
- children: term
81
- }), /* @__PURE__ */ jsx("button", {
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: () => search.removeTerm(term),
84
- "aria-label": list.removeSearchTerm(term),
85
- className: "shrink-0 rounded p-0.5 text-(--theme-muted-text-color) hover:text-(--theme-app) transition-colors",
86
- children: /* @__PURE__ */ jsx(RemoveIcon, { className: "w-2.5 h-2.5" })
87
- })]
88
- }, term)),
89
- /* @__PURE__ */ jsx("input", {
90
- ref: search.inputRef,
91
- value: search.filter,
92
- onChange: (e) => search.setFilter(e.target.value),
93
- onFocus: search.openSearch,
94
- "aria-label": label,
95
- onKeyDown: (e) => {
96
- if (e.key === "Escape" && search.searchOpen) {
97
- e.preventDefault();
98
- search.closeSearch();
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__ */ jsx("button", {
206
+ children: /* @__PURE__ */ jsxs("button", {
207
+ ref,
123
208
  type: "button",
124
- onClick: search.openSearch,
125
- "aria-label": 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({ total, matches, filterActive, noun, nounPlural }) {
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 count = (n) => formatInteger(n, locale);
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: "text-[13px] text-(--theme-muted-text-color)",
151
- children: filterActive ? t.matchCount(count(matches), count(total), nounPlural) : t.count(count(total), total === 1 ? noun : nounPlural)
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.92.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": {
@@ -76,7 +76,12 @@
76
76
  "types": "./dist/ai/adapter/mock/index.d.ts",
77
77
  "import": "./dist/ai/adapter/mock/index.js"
78
78
  },
79
- "./ai.css": "./dist/ai/ai.css"
79
+ "./ai.css": "./dist/ai/ai.css",
80
+ "./app-frame": {
81
+ "types": "./dist/app-frame/index.d.ts",
82
+ "import": "./dist/app-frame/index.js"
83
+ },
84
+ "./app-frame.css": "./dist/app-frame/app-frame.css"
80
85
  },
81
86
  "publishConfig": {
82
87
  "registry": "https://registry.npmjs.org"
@@ -148,7 +153,7 @@
148
153
  "scripts": {
149
154
  "build": "vite build && pnpm build:types && pnpm build:css",
150
155
  "build:types": "tsc -p tsconfig.build.json && node scripts/declaration-extensions.mjs",
151
- "build:css": "mkdir -p dist/styles dist/logviewer dist/ai && cp src/styles/theme.css src/styles/base.css src/styles/fonts.css src/styles/*.woff2 dist/styles/ && tailwindcss -i ./src/styles.css -o ./dist/styles.css --minify && cp src/logviewer/logviewer.css dist/logviewer/ && cp src/ai/ai.css dist/ai/",
156
+ "build:css": "mkdir -p dist/styles dist/logviewer dist/ai && cp src/styles/theme.css src/styles/base.css src/styles/fonts.css src/styles/*.woff2 dist/styles/ && tailwindcss -i ./src/styles.css -o ./dist/styles.css --minify && cp src/logviewer/logviewer.css dist/logviewer/ && cp src/ai/ai.css dist/ai/ && mkdir -p dist/app-frame && cp src/app-frame/app-frame.css dist/app-frame/",
152
157
  "clean": "rm -rf dist",
153
158
  "test": "vitest run",
154
159
  "lint:package": "publint --strict",