@eifi1/ui-kit 0.56.0 → 0.57.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.
Files changed (63) hide show
  1. package/README.md +9 -6
  2. package/dist/components/data-table.d.ts +16 -0
  3. package/dist/components/data-table.js +17 -0
  4. package/dist/components/data-table.js.map +1 -1
  5. package/dist/components/quantity-field.d.ts +61 -0
  6. package/dist/components/quantity-field.js +95 -0
  7. package/dist/components/quantity-field.js.map +1 -0
  8. package/dist/components/quantity-input.d.ts +139 -0
  9. package/dist/components/quantity-input.js +188 -0
  10. package/dist/components/quantity-input.js.map +1 -0
  11. package/dist/components/quantity-labels.d.ts +2 -0
  12. package/dist/components/quantity-labels.js +11 -0
  13. package/dist/components/quantity-labels.js.map +1 -0
  14. package/dist/eslint/index.js +1 -1
  15. package/dist/feedback/feedback-inbox-bulk.d.ts +88 -0
  16. package/dist/feedback/feedback-inbox-bulk.js +316 -0
  17. package/dist/feedback/feedback-inbox-bulk.js.map +1 -0
  18. package/dist/feedback/feedback-inbox-page.d.ts +47 -3
  19. package/dist/feedback/feedback-inbox-page.js +19 -4
  20. package/dist/feedback/feedback-inbox-page.js.map +1 -1
  21. package/dist/feedback/feedback-row-detail.d.ts +5 -1
  22. package/dist/feedback/feedback-row-detail.js.map +1 -1
  23. package/dist/feedback/feedback-table-labels.js +10 -1
  24. package/dist/feedback/feedback-table-labels.js.map +1 -1
  25. package/dist/feedback/feedback-table.d.ts +26 -0
  26. package/dist/feedback/feedback-table.js.map +1 -1
  27. package/dist/feedback.d.ts +1 -1
  28. package/dist/feedback.js.map +1 -1
  29. package/dist/i18n/defaults.js +3 -1
  30. package/dist/i18n/defaults.js.map +1 -1
  31. package/dist/i18n/german.js +20 -1
  32. package/dist/i18n/german.js.map +1 -1
  33. package/dist/i18n/kit-labels.d.ts +4 -0
  34. package/dist/i18n/kit-labels.js.map +1 -1
  35. package/dist/i18n/locales/en.js +26 -3
  36. package/dist/i18n/locales/en.js.map +3 -3
  37. package/dist/i18n/locales/es.js +20 -1
  38. package/dist/i18n/locales/es.js.map +1 -1
  39. package/dist/i18n/locales/fr.js +25 -1
  40. package/dist/i18n/locales/fr.js.map +1 -1
  41. package/dist/i18n/locales/hu.js +22 -1
  42. package/dist/i18n/locales/hu.js.map +1 -1
  43. package/dist/i18n/locales/it.js +21 -1
  44. package/dist/i18n/locales/it.js.map +1 -1
  45. package/dist/i18n/locales/zh.js +20 -1
  46. package/dist/i18n/locales/zh.js.map +1 -1
  47. package/dist/i18n/review.js +12 -1
  48. package/dist/i18n/review.js.map +1 -1
  49. package/dist/index.d.ts +6 -0
  50. package/dist/index.js +23 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/lib/quantity.d.ts +107 -0
  53. package/dist/lib/quantity.js +166 -0
  54. package/dist/lib/quantity.js.map +1 -0
  55. package/dist/rhf/fields.d.ts +28 -0
  56. package/dist/rhf/fields.js +82 -0
  57. package/dist/rhf/fields.js.map +1 -1
  58. package/dist/rhf.js.map +1 -1
  59. package/dist/theme/contrast.d.ts +1 -1
  60. package/dist/theme/palette-store.d.ts +1 -1
  61. package/dist/theme/text-size.d.ts +1 -1
  62. package/dist/theme/theme-store.d.ts +1 -1
  63. package/package.json +2 -3
@@ -0,0 +1,88 @@
1
+ import type { DataTableProps } from "../components/data-table.js";
2
+ import type { CommitScope } from "../components/write-lock.js";
3
+ import type { LabelOverride } from "../i18n/kit-labels.js";
4
+ import type { FeedbackRecord } from "./feedback-record.js";
5
+ import type { FeedbackDetailUpdateAsync } from "./feedback-row-detail.js";
6
+ import type { FeedbackInboxBulk } from "./feedback-inbox-page.js";
7
+ import { type FeedbackPageLabels } from "./feedback-table.js";
8
+ import { type FeedbackCategory, type FeedbackStatus } from "./feedback-vocabulary.js";
9
+ /**
10
+ * The inbox's multi-select and bulk edit (0.57, keksdose dev #663 — Marcel: *"I want to
11
+ * have multiselect and bulk edit (back) for the feedback table"*; docs/feedback-harmonization.md
12
+ * §13.6). The inbox the kit assembles (0.55, §13) had no selection at all, so an admin
13
+ * changed a dozen stale reports one row at a time.
14
+ *
15
+ * **No bulk endpoint, on purpose.** keksdose has none, and none of the three needs one:
16
+ * each report's change has to pass the server's own rules for that report (§3.4), so the bar
17
+ * loops the page's ONE `update` mutation — `PATCH /feedback/{id}` per report — exactly as a
18
+ * tap on the status cell does. An app gets the bulk edit with no wiring of its own. It
19
+ * notifies nobody, its Undo neither: a status, a category or the visibility alone tells no
20
+ * one (decision 13, §9.11); only a reply or a comment does, and the bar writes neither.
21
+ *
22
+ * What it changes: the **status**, the **category** (never to CRASH — §3.4 answers 422;
23
+ * CRASH → BUG is an ordinary admin edit) and, with the inbox's `publicSwitch`, **Visible to
24
+ * others** (§9.3; a CRASH is private whatever its flag, and a row without `public` comes
25
+ * from a server without public parts — both are left alone, as the detail's switch is not
26
+ * drawn on them). A row already at the value is not sent.
27
+ *
28
+ * **One Undo for the batch**: the toast the single change has (§4.5, 8 s), naming how many
29
+ * changed; Undo puts EACH changed report back to what IT was — the selection may mix
30
+ * statuses — and only the reports that changed. A report whose PATCH failed (the
31
+ * mutation's `onError` toasts the server's sentence, as for a single change) is not in the
32
+ * Undo, and STAYS SELECTED, so the next press retries exactly those; the bar's count says
33
+ * how many.
34
+ */
35
+ /** Which bulk change: the {@link FeedbackDetailPatch} key it sends. */
36
+ type BulkField = NonNullable<FeedbackInboxBulk<FeedbackRecord>["pending"]>;
37
+ /** How many PATCHes a bulk change keeps in flight. Each one is a full update on the
38
+ * server, with its rules, so a trickle rather than a burst of 20, and still about a second
39
+ * for the 20 rather than the 20 round trips back to back a plain loop would take.
40
+ *
41
+ * @internal Not part of `/feedback` or the barrel. */
42
+ export declare const FEEDBACK_BULK_CONCURRENCY = 3;
43
+ /**
44
+ * The selected rows a bulk change would actually SEND — those not at the value already
45
+ * (and, for the flag, those that can carry it). The bar disables a choice that would send
46
+ * nothing; the change sends exactly these.
47
+ *
48
+ * @internal Not part of `/feedback` or the barrel.
49
+ */
50
+ export declare function feedbackBulkTargets<T extends FeedbackRecord>(rows: readonly T[], field: BulkField, value: FeedbackStatus | FeedbackCategory | boolean): T[];
51
+ /**
52
+ * The selection and the bulk changes, for {@link useFeedbackInbox}: the table's `selection`
53
+ * and `inbox.bulk`, both `undefined` while `on` is false (not the admin inbox, or the app's
54
+ * `selectable={false}`).
55
+ *
56
+ * @internal Not part of `/feedback` or the barrel.
57
+ */
58
+ export declare function useFeedbackInboxBulk<T extends FeedbackRecord>({ on, rows: shown, update, publicSwitch, labels, }: {
59
+ on: boolean;
60
+ /** The rows the inbox hands its table (newest first, behind the awaiting toggle). */
61
+ rows: readonly T[];
62
+ update: FeedbackDetailUpdateAsync;
63
+ publicSwitch: boolean;
64
+ labels?: LabelOverride<FeedbackPageLabels>;
65
+ }): {
66
+ bulk?: FeedbackInboxBulk<T>;
67
+ selection?: DataTableProps<T>["selection"];
68
+ };
69
+ /**
70
+ * The inbox's bulk bar: the count, Clear, and Set status / Set category / Visibility, each
71
+ * a list of the values — the statuses in chain order with their glyphs, the categories
72
+ * less CRASH, on and off. Rendered unconditionally, as `BulkActionBar` asks.
73
+ *
74
+ * On a desktop the strip is OPEN from the start, at "0 selected" with its lists waiting:
75
+ * it sits in the flow above the table, so showing it with the first ticked row pushed every
76
+ * row down by its height — away from the pointer reaching for the next box — and clearing
77
+ * pulled them back up (the 0.57 screenshot check). On a phone the card floats over the page
78
+ * and moves nothing, so it still comes with the first selected card.
79
+ *
80
+ * @internal Drawn by {@link FeedbackInbox}; not part of `/feedback` or the barrel.
81
+ */
82
+ export declare function FeedbackInboxBulkBar<T extends FeedbackRecord>({ bulk, commit, labels, }: {
83
+ bulk: FeedbackInboxBulk<T>;
84
+ /** The table's status writes' lock (`commit`): a bulk change is the same write. */
85
+ commit?: CommitScope;
86
+ labels?: LabelOverride<FeedbackPageLabels>;
87
+ }): import("react").JSX.Element;
88
+ export {};
@@ -0,0 +1,316 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useMemo, useRef, useState } from "react";
4
+ import { CircleDot, Eye, EyeOff, Tag } from "lucide-react";
5
+ import { BulkActionBar } from "../components/bulk-action-bar.js";
6
+ import { Button } from "../components/button.js";
7
+ import { Popover } from "../components/popover.js";
8
+ import { useBreakpoint } from "../hooks/use-breakpoint.js";
9
+ import { toast } from "../components/toast.js";
10
+ import { useFeedbackCategoryLabels, useFeedbackStatusLabels, useFeedbackToastLabels } from "./feedback-labels.js";
11
+ import { FEEDBACK_UNDO_DURATION } from "./feedback-status-undo.js";
12
+ import { useFeedbackPageLabels } from "./feedback-table.js";
13
+ import {
14
+ FEEDBACK_CATEGORY_META,
15
+ FEEDBACK_CATEGORY_ORDER,
16
+ FEEDBACK_STATUS_META,
17
+ FEEDBACK_STATUS_ORDER
18
+ } from "./feedback-vocabulary.js";
19
+ const FEEDBACK_BULK_CONCURRENCY = 3;
20
+ const MOVABLE_CATEGORIES = FEEDBACK_CATEGORY_ORDER.filter((category) => category !== "CRASH");
21
+ const canBePublic = (row) => row.category !== "CRASH" && row.public != null;
22
+ function feedbackBulkTargets(rows, field, value) {
23
+ switch (field) {
24
+ case "status":
25
+ return rows.filter((row) => row.status !== value);
26
+ case "category":
27
+ return rows.filter((row) => row.category !== value);
28
+ case "public":
29
+ return rows.filter((row) => canBePublic(row) && row.public !== value);
30
+ }
31
+ }
32
+ async function eachPatch(call, patches) {
33
+ const landed = patches.map(() => false);
34
+ let next = 0;
35
+ const worker = async () => {
36
+ while (next < patches.length) {
37
+ const at = next++;
38
+ try {
39
+ await call(patches[at]);
40
+ landed[at] = true;
41
+ } catch {
42
+ }
43
+ }
44
+ };
45
+ await Promise.all(Array.from({ length: Math.min(FEEDBACK_BULK_CONCURRENCY, patches.length) }, worker));
46
+ return landed;
47
+ }
48
+ const without = (set, ids) => {
49
+ const next = new Set(set);
50
+ let changed = false;
51
+ for (const id of ids) changed = next.delete(id) || changed;
52
+ return changed ? next : set;
53
+ };
54
+ function useFeedbackInboxBulk({
55
+ on,
56
+ rows: shown,
57
+ update,
58
+ publicSwitch,
59
+ labels
60
+ }) {
61
+ const words = useFeedbackPageLabels(labels);
62
+ const toastWords = useFeedbackToastLabels();
63
+ const statusNames = useFeedbackStatusLabels();
64
+ const categoryNames = useFeedbackCategoryLabels();
65
+ const [picked, setPicked] = useState(() => /* @__PURE__ */ new Set());
66
+ const [filteredIds, setFilteredIds] = useState(null);
67
+ const [pending, setPending] = useState(null);
68
+ const running = useRef(false);
69
+ const visible = useMemo(
70
+ () => filteredIds ? shown.filter((row) => filteredIds.has(row.id)) : shown,
71
+ [shown, filteredIds]
72
+ );
73
+ const rows = useMemo(() => visible.filter((row) => picked.has(row.id)), [visible, picked]);
74
+ if (!on) return {};
75
+ const toggle = (many, checked) => setPicked((prev) => {
76
+ const next = new Set(prev);
77
+ for (const row of many) {
78
+ if (checked) next.add(row.id);
79
+ else next.delete(row.id);
80
+ }
81
+ return next;
82
+ });
83
+ const run = async (field, name, targets, to, back) => {
84
+ if (running.current || targets.length === 0) return;
85
+ running.current = true;
86
+ setPending(field);
87
+ try {
88
+ const landed = await eachPatch(update, targets.map(to));
89
+ const changed = targets.filter((_, at) => landed[at]);
90
+ const failed = targets.length - changed.length;
91
+ setPicked(
92
+ (prev) => without(
93
+ prev,
94
+ changed.map((row) => row.id)
95
+ )
96
+ );
97
+ if (changed.length > 0) {
98
+ toast.undo(words.bulkChanged(changed.length, name), {
99
+ label: toastWords.statusUndo,
100
+ duration: FEEDBACK_UNDO_DURATION,
101
+ // Each report back to what IT was, at the press: `back` reads the row as the
102
+ // batch found it. Only the ones that changed.
103
+ onUndo: () => void restore(changed.map(back))
104
+ });
105
+ }
106
+ if (failed > 0) toast.error(words.bulkFailed(failed));
107
+ } finally {
108
+ running.current = false;
109
+ setPending(null);
110
+ }
111
+ };
112
+ const restore = async (patches) => {
113
+ const landed = await eachPatch(update, patches);
114
+ const restored = landed.filter(Boolean).length;
115
+ if (restored > 0) toast.success(words.bulkRestored(restored));
116
+ if (restored < patches.length) toast.error(words.bulkRestoreFailed(patches.length - restored));
117
+ };
118
+ const bulk = {
119
+ rows,
120
+ clear: () => setPicked(/* @__PURE__ */ new Set()),
121
+ setStatus: (status) => run(
122
+ "status",
123
+ statusNames[status] ?? status,
124
+ feedbackBulkTargets(rows, "status", status),
125
+ (row) => ({ id: row.id, status }),
126
+ (row) => ({ id: row.id, status: row.status })
127
+ ),
128
+ setCategory: (category) => category === "CRASH" ? Promise.resolve() : run(
129
+ "category",
130
+ categoryNames[category] ?? category,
131
+ feedbackBulkTargets(rows, "category", category),
132
+ (row) => ({ id: row.id, category }),
133
+ (row) => ({ id: row.id, category: row.category })
134
+ ),
135
+ setPublic: publicSwitch ? (next) => run(
136
+ "public",
137
+ next ? words.bulkPublicOn : words.bulkPublicOff,
138
+ feedbackBulkTargets(rows, "public", next),
139
+ (row) => ({ id: row.id, public: next }),
140
+ (row) => ({ id: row.id, public: row.public === true })
141
+ ) : void 0,
142
+ pending
143
+ };
144
+ const selection = {
145
+ isSelected: (row) => picked.has(row.id),
146
+ onToggle: (row, checked) => toggle([row], checked),
147
+ onToggleMany: (many, checked) => toggle(many, checked),
148
+ // "All" is what the admin can see: the table's filters and the awaiting toggle
149
+ // applied, every page — never a row a filter hides.
150
+ allSelected: visible.length > 0 && rows.length === visible.length,
151
+ someSelected: rows.length > 0 && rows.length < visible.length,
152
+ onToggleAll: (checked) => checked ? toggle(visible, true) : setPicked(/* @__PURE__ */ new Set()),
153
+ onFilteredRowsChange: (filtered) => {
154
+ const ids = new Set(filtered.map((row) => row.id));
155
+ setFilteredIds(ids);
156
+ setPicked(
157
+ (prev) => without(
158
+ prev,
159
+ [...prev].filter((id) => !ids.has(id))
160
+ )
161
+ );
162
+ }
163
+ };
164
+ return { bulk, selection };
165
+ }
166
+ const BAR_VARIANT = { base: "floating", md: "sticky" };
167
+ function BulkChoiceMenu({
168
+ label,
169
+ icon: Icon,
170
+ choices,
171
+ empty,
172
+ busy,
173
+ running,
174
+ commit
175
+ }) {
176
+ return /* @__PURE__ */ jsx(
177
+ Popover,
178
+ {
179
+ "aria-label": label,
180
+ width: "16rem",
181
+ className: "p-1",
182
+ trigger: ({ open, toggle, ref }) => /* @__PURE__ */ jsxs(
183
+ Button,
184
+ {
185
+ ref,
186
+ variant: "secondary",
187
+ size: "sm",
188
+ "aria-haspopup": "dialog",
189
+ "aria-expanded": open,
190
+ pending: running,
191
+ disabled: empty || busy && !running,
192
+ commit,
193
+ onClick: toggle,
194
+ children: [
195
+ /* @__PURE__ */ jsx(Icon, { className: "size-4", "aria-hidden": true }),
196
+ label
197
+ ]
198
+ }
199
+ ),
200
+ children: (close) => /* @__PURE__ */ jsx("ul", { className: "space-y-0.5", children: choices.map(({ key, label: choice, icon: ChoiceIcon, sends, pick }) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
201
+ Button,
202
+ {
203
+ variant: "ghost",
204
+ size: "sm",
205
+ className: "w-full justify-start text-start",
206
+ disabled: !sends,
207
+ commit,
208
+ onClick: () => {
209
+ close();
210
+ void pick();
211
+ },
212
+ children: [
213
+ /* @__PURE__ */ jsx(ChoiceIcon, { className: "size-4", "aria-hidden": true }),
214
+ choice
215
+ ]
216
+ }
217
+ ) }, key)) })
218
+ }
219
+ );
220
+ }
221
+ function FeedbackInboxBulkBar({
222
+ bulk,
223
+ commit,
224
+ labels
225
+ }) {
226
+ const words = useFeedbackPageLabels(labels);
227
+ const statusNames = useFeedbackStatusLabels();
228
+ const categoryNames = useFeedbackCategoryLabels();
229
+ const { rows, pending, setPublic } = bulk;
230
+ const busy = pending !== null;
231
+ const empty = rows.length === 0;
232
+ const desktop = useBreakpoint("md");
233
+ const sends = (field, value) => feedbackBulkTargets(rows, field, value).length > 0;
234
+ return /* @__PURE__ */ jsxs(
235
+ BulkActionBar,
236
+ {
237
+ count: rows.length,
238
+ open: desktop || void 0,
239
+ onClear: bulk.clear,
240
+ variant: BAR_VARIANT,
241
+ className: "md:shrink-0",
242
+ children: [
243
+ /* @__PURE__ */ jsx(
244
+ BulkChoiceMenu,
245
+ {
246
+ label: words.bulkStatus,
247
+ icon: CircleDot,
248
+ empty,
249
+ busy,
250
+ running: pending === "status",
251
+ commit,
252
+ choices: FEEDBACK_STATUS_ORDER.map((status) => ({
253
+ key: status,
254
+ label: statusNames[status],
255
+ icon: FEEDBACK_STATUS_META[status].icon,
256
+ sends: sends("status", status),
257
+ pick: () => bulk.setStatus(status)
258
+ }))
259
+ }
260
+ ),
261
+ /* @__PURE__ */ jsx(
262
+ BulkChoiceMenu,
263
+ {
264
+ label: words.bulkCategory,
265
+ icon: Tag,
266
+ empty,
267
+ busy,
268
+ running: pending === "category",
269
+ commit,
270
+ choices: MOVABLE_CATEGORIES.map((category) => ({
271
+ key: category,
272
+ label: categoryNames[category],
273
+ icon: FEEDBACK_CATEGORY_META[category].icon,
274
+ sends: sends("category", category),
275
+ pick: () => bulk.setCategory(category)
276
+ }))
277
+ }
278
+ ),
279
+ setPublic && /* @__PURE__ */ jsx(
280
+ BulkChoiceMenu,
281
+ {
282
+ label: words.bulkVisibility,
283
+ icon: Eye,
284
+ empty,
285
+ busy,
286
+ running: pending === "public",
287
+ commit,
288
+ choices: [
289
+ {
290
+ key: "on",
291
+ label: words.bulkPublicOn,
292
+ icon: Eye,
293
+ sends: sends("public", true),
294
+ pick: () => setPublic(true)
295
+ },
296
+ {
297
+ key: "off",
298
+ label: words.bulkPublicOff,
299
+ icon: EyeOff,
300
+ sends: sends("public", false),
301
+ pick: () => setPublic(false)
302
+ }
303
+ ]
304
+ }
305
+ )
306
+ ]
307
+ }
308
+ );
309
+ }
310
+ export {
311
+ FEEDBACK_BULK_CONCURRENCY,
312
+ FeedbackInboxBulkBar,
313
+ feedbackBulkTargets,
314
+ useFeedbackInboxBulk
315
+ };
316
+ //# sourceMappingURL=feedback-inbox-bulk.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/feedback/feedback-inbox-bulk.tsx"],"sourcesContent":["import { useMemo, useRef, useState } from \"react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { CircleDot, Eye, EyeOff, Tag } from \"lucide-react\";\nimport { BulkActionBar, type ResponsiveBulkActionBarVariant } from \"../components/bulk-action-bar\";\nimport { Button } from \"../components/button\";\nimport type { DataTableProps } from \"../components/data-table\";\nimport { Popover } from \"../components/popover\";\nimport { useBreakpoint } from \"../hooks/use-breakpoint\";\nimport { toast } from \"../components/toast\";\nimport type { CommitScope } from \"../components/write-lock\";\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { useFeedbackCategoryLabels, useFeedbackStatusLabels, useFeedbackToastLabels } from \"./feedback-labels\";\nimport type { FeedbackRecord } from \"./feedback-record\";\nimport type { FeedbackDetailPatch, FeedbackDetailUpdateAsync } from \"./feedback-row-detail\";\n// A type, declared beside the inbox's other public types (the README lists it there); the\n// page imports this module's runtime, so only the type goes this way.\nimport type { FeedbackInboxBulk } from \"./feedback-inbox-page\";\nimport { FEEDBACK_UNDO_DURATION } from \"./feedback-status-undo\";\nimport { useFeedbackPageLabels, type FeedbackPageLabels } from \"./feedback-table\";\nimport {\n FEEDBACK_CATEGORY_META,\n FEEDBACK_CATEGORY_ORDER,\n FEEDBACK_STATUS_META,\n FEEDBACK_STATUS_ORDER,\n type FeedbackCategory,\n type FeedbackStatus,\n} from \"./feedback-vocabulary\";\n\n/**\n * The inbox's multi-select and bulk edit (0.57, keksdose dev #663 — Marcel: *\"I want to\n * have multiselect and bulk edit (back) for the feedback table\"*; docs/feedback-harmonization.md\n * §13.6). The inbox the kit assembles (0.55, §13) had no selection at all, so an admin\n * changed a dozen stale reports one row at a time.\n *\n * **No bulk endpoint, on purpose.** keksdose has none, and none of the three needs one:\n * each report's change has to pass the server's own rules for that report (§3.4), so the bar\n * loops the page's ONE `update` mutation — `PATCH /feedback/{id}` per report — exactly as a\n * tap on the status cell does. An app gets the bulk edit with no wiring of its own. It\n * notifies nobody, its Undo neither: a status, a category or the visibility alone tells no\n * one (decision 13, §9.11); only a reply or a comment does, and the bar writes neither.\n *\n * What it changes: the **status**, the **category** (never to CRASH — §3.4 answers 422;\n * CRASH → BUG is an ordinary admin edit) and, with the inbox's `publicSwitch`, **Visible to\n * others** (§9.3; a CRASH is private whatever its flag, and a row without `public` comes\n * from a server without public parts — both are left alone, as the detail's switch is not\n * drawn on them). A row already at the value is not sent.\n *\n * **One Undo for the batch**: the toast the single change has (§4.5, 8 s), naming how many\n * changed; Undo puts EACH changed report back to what IT was — the selection may mix\n * statuses — and only the reports that changed. A report whose PATCH failed (the\n * mutation's `onError` toasts the server's sentence, as for a single change) is not in the\n * Undo, and STAYS SELECTED, so the next press retries exactly those; the bar's count says\n * how many.\n */\n\n/** Which bulk change: the {@link FeedbackDetailPatch} key it sends. */\ntype BulkField = NonNullable<FeedbackInboxBulk<FeedbackRecord>[\"pending\"]>;\n\n/** How many PATCHes a bulk change keeps in flight. Each one is a full update on the\n * server, with its rules, so a trickle rather than a burst of 20, and still about a second\n * for the 20 rather than the 20 round trips back to back a plain loop would take.\n *\n * @internal Not part of `/feedback` or the barrel. */\nexport const FEEDBACK_BULK_CONCURRENCY = 3;\n\n/** The categories a report can be MOVED to — the vocabulary less CRASH, which is filed by\n * the crash reporter only (§3.4: re-labelling to CRASH is a 422). */\nconst MOVABLE_CATEGORIES = FEEDBACK_CATEGORY_ORDER.filter((category) => category !== \"CRASH\");\n\n/** A report that can carry Visible to others: the detail's switch's rule (§9.3). */\nconst canBePublic = (row: FeedbackRecord) => row.category !== \"CRASH\" && row.public != null;\n\n/**\n * The selected rows a bulk change would actually SEND — those not at the value already\n * (and, for the flag, those that can carry it). The bar disables a choice that would send\n * nothing; the change sends exactly these.\n *\n * @internal Not part of `/feedback` or the barrel.\n */\nexport function feedbackBulkTargets<T extends FeedbackRecord>(\n rows: readonly T[],\n field: BulkField,\n value: FeedbackStatus | FeedbackCategory | boolean,\n): T[] {\n switch (field) {\n case \"status\":\n return rows.filter((row) => row.status !== value);\n case \"category\":\n return rows.filter((row) => row.category !== value);\n case \"public\":\n return rows.filter((row) => canBePublic(row) && row.public !== value);\n }\n}\n\n/**\n * Each patch through `call`, {@link FEEDBACK_BULK_CONCURRENCY} at a time, every one\n * answered before it resolves — and which of them landed, by index. A rejection is the\n * mutation's own to report (its `onError`), so it is only counted here.\n */\nasync function eachPatch(call: FeedbackDetailUpdateAsync, patches: readonly FeedbackDetailPatch[]) {\n const landed = patches.map(() => false);\n let next = 0;\n const worker = async () => {\n while (next < patches.length) {\n const at = next++;\n try {\n await call(patches[at]);\n landed[at] = true;\n } catch {\n // Counted, not reported: the mutation's `onError` has said why.\n }\n }\n };\n await Promise.all(Array.from({ length: Math.min(FEEDBACK_BULK_CONCURRENCY, patches.length) }, worker));\n return landed;\n}\n\nconst without = (set: ReadonlySet<number>, ids: Iterable<number>): ReadonlySet<number> => {\n const next = new Set(set);\n let changed = false;\n for (const id of ids) changed = next.delete(id) || changed;\n return changed ? next : set;\n};\n\n/**\n * The selection and the bulk changes, for {@link useFeedbackInbox}: the table's `selection`\n * and `inbox.bulk`, both `undefined` while `on` is false (not the admin inbox, or the app's\n * `selectable={false}`).\n *\n * @internal Not part of `/feedback` or the barrel.\n */\nexport function useFeedbackInboxBulk<T extends FeedbackRecord>({\n on,\n rows: shown,\n update,\n publicSwitch,\n labels,\n}: {\n on: boolean;\n /** The rows the inbox hands its table (newest first, behind the awaiting toggle). */\n rows: readonly T[];\n update: FeedbackDetailUpdateAsync;\n publicSwitch: boolean;\n labels?: LabelOverride<FeedbackPageLabels>;\n}): { bulk?: FeedbackInboxBulk<T>; selection?: DataTableProps<T>[\"selection\"] } {\n const words = useFeedbackPageLabels(labels);\n const toastWords = useFeedbackToastLabels();\n const statusNames = useFeedbackStatusLabels();\n const categoryNames = useFeedbackCategoryLabels();\n const [picked, setPicked] = useState<ReadonlySet<number>>(() => new Set());\n // The table's filtered set, as it reports it — `null` until its first report, when\n // nothing the table shows is filtered yet.\n const [filteredIds, setFilteredIds] = useState<ReadonlySet<number> | null>(null);\n const [pending, setPending] = useState<BulkField | null>(null);\n // Read at the press, not from the render: two presses in one frame start one change.\n const running = useRef(false);\n\n const visible = useMemo(\n () => (filteredIds ? shown.filter((row) => filteredIds.has(row.id)) : shown),\n [shown, filteredIds],\n );\n const rows = useMemo(() => visible.filter((row) => picked.has(row.id)), [visible, picked]);\n\n if (!on) return {};\n\n const toggle = (many: readonly T[], checked: boolean) =>\n setPicked((prev) => {\n const next = new Set(prev);\n for (const row of many) {\n if (checked) next.add(row.id);\n else next.delete(row.id);\n }\n return next;\n });\n\n const run = async (\n field: BulkField,\n name: string,\n targets: readonly T[],\n to: (row: T) => FeedbackDetailPatch,\n back: (row: T) => FeedbackDetailPatch,\n ) => {\n if (running.current || targets.length === 0) return;\n running.current = true;\n setPending(field);\n try {\n const landed = await eachPatch(update, targets.map(to));\n const changed = targets.filter((_, at) => landed[at]);\n const failed = targets.length - changed.length;\n // What changed leaves the selection; what failed stays in it, ready to retry — and\n // a row ticked while the batch was out stays too.\n setPicked((prev) =>\n without(\n prev,\n changed.map((row) => row.id),\n ),\n );\n if (changed.length > 0) {\n toast.undo(words.bulkChanged(changed.length, name), {\n label: toastWords.statusUndo,\n duration: FEEDBACK_UNDO_DURATION,\n // Each report back to what IT was, at the press: `back` reads the row as the\n // batch found it. Only the ones that changed.\n onUndo: () => void restore(changed.map(back)),\n });\n }\n if (failed > 0) toast.error(words.bulkFailed(failed));\n } finally {\n running.current = false;\n setPending(null);\n }\n };\n\n const restore = async (patches: readonly FeedbackDetailPatch[]) => {\n const landed = await eachPatch(update, patches);\n const restored = landed.filter(Boolean).length;\n if (restored > 0) toast.success(words.bulkRestored(restored));\n if (restored < patches.length) toast.error(words.bulkRestoreFailed(patches.length - restored));\n };\n\n const bulk: FeedbackInboxBulk<T> = {\n rows,\n clear: () => setPicked(new Set()),\n setStatus: (status) =>\n run(\n \"status\",\n statusNames[status] ?? status,\n feedbackBulkTargets(rows, \"status\", status),\n (row) => ({ id: row.id, status }),\n (row) => ({ id: row.id, status: row.status }),\n ),\n setCategory: (category) =>\n category === \"CRASH\"\n ? Promise.resolve()\n : run(\n \"category\",\n categoryNames[category] ?? category,\n feedbackBulkTargets(rows, \"category\", category),\n (row) => ({ id: row.id, category }),\n (row) => ({ id: row.id, category: row.category }),\n ),\n setPublic: publicSwitch\n ? (next) =>\n run(\n \"public\",\n next ? words.bulkPublicOn : words.bulkPublicOff,\n feedbackBulkTargets(rows, \"public\", next),\n (row) => ({ id: row.id, public: next }),\n (row) => ({ id: row.id, public: row.public === true }),\n )\n : undefined,\n pending,\n };\n\n const selection: NonNullable<DataTableProps<T>[\"selection\"]> = {\n isSelected: (row) => picked.has(row.id),\n onToggle: (row, checked) => toggle([row], checked),\n onToggleMany: (many, checked) => toggle(many, checked),\n // \"All\" is what the admin can see: the table's filters and the awaiting toggle\n // applied, every page — never a row a filter hides.\n allSelected: visible.length > 0 && rows.length === visible.length,\n someSelected: rows.length > 0 && rows.length < visible.length,\n onToggleAll: (checked) => (checked ? toggle(visible, true) : setPicked(new Set())),\n onFilteredRowsChange: (filtered) => {\n const ids = new Set(filtered.map((row) => row.id));\n setFilteredIds(ids);\n // A selection a new filter hides would be acted on unseen — the rule\n // TranslationReviewPanel keeps — so it leaves the selection.\n setPicked((prev) =>\n without(\n prev,\n [...prev].filter((id) => !ids.has(id)),\n ),\n );\n },\n };\n\n return { bulk, selection };\n}\n\n/** The phone's floating card above the bottom nav, the desktop's tinted strip over the\n * table — `BulkActionBar`'s responsive pair (keksdose's payees bar). */\nconst BAR_VARIANT: ResponsiveBulkActionBarVariant = { base: \"floating\", md: \"sticky\" };\n\ninterface BulkChoice {\n key: string;\n label: string;\n icon: LucideIcon;\n /** Some selected row would change: otherwise the choice is disabled. */\n sends: boolean;\n pick: () => Promise<void>;\n}\n\n/** One of the bar's buttons and the list it opens — RowActions' \"⋯\" menu, the kit's one\n * popover of actions: a dialog-shaped panel of ghost buttons, closed by the pick. */\nfunction BulkChoiceMenu({\n label,\n icon: Icon,\n choices,\n empty,\n busy,\n running,\n commit,\n}: {\n label: string;\n icon: LucideIcon;\n choices: readonly BulkChoice[];\n /** Nothing selected yet (the desktop's strip, shown from the start): the list waits. */\n empty: boolean;\n /** Some bulk change is out: no second one starts. */\n busy: boolean;\n /** This one is. */\n running: boolean;\n commit?: CommitScope;\n}) {\n return (\n <Popover\n aria-label={label}\n width=\"16rem\"\n className=\"p-1\"\n trigger={({ open, toggle, ref }) => (\n <Button\n ref={ref}\n variant=\"secondary\"\n size=\"sm\"\n aria-haspopup=\"dialog\"\n aria-expanded={open}\n pending={running}\n disabled={empty || (busy && !running)}\n commit={commit}\n onClick={toggle}\n >\n <Icon className=\"size-4\" aria-hidden />\n {label}\n </Button>\n )}\n >\n {(close) => (\n <ul className=\"space-y-0.5\">\n {choices.map(({ key, label: choice, icon: ChoiceIcon, sends, pick }) => (\n <li key={key}>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-full justify-start text-start\"\n disabled={!sends}\n commit={commit}\n onClick={() => {\n close();\n void pick();\n }}\n >\n <ChoiceIcon className=\"size-4\" aria-hidden />\n {choice}\n </Button>\n </li>\n ))}\n </ul>\n )}\n </Popover>\n );\n}\n\n/**\n * The inbox's bulk bar: the count, Clear, and Set status / Set category / Visibility, each\n * a list of the values — the statuses in chain order with their glyphs, the categories\n * less CRASH, on and off. Rendered unconditionally, as `BulkActionBar` asks.\n *\n * On a desktop the strip is OPEN from the start, at \"0 selected\" with its lists waiting:\n * it sits in the flow above the table, so showing it with the first ticked row pushed every\n * row down by its height — away from the pointer reaching for the next box — and clearing\n * pulled them back up (the 0.57 screenshot check). On a phone the card floats over the page\n * and moves nothing, so it still comes with the first selected card.\n *\n * @internal Drawn by {@link FeedbackInbox}; not part of `/feedback` or the barrel.\n */\nexport function FeedbackInboxBulkBar<T extends FeedbackRecord>({\n bulk,\n commit,\n labels,\n}: {\n bulk: FeedbackInboxBulk<T>;\n /** The table's status writes' lock (`commit`): a bulk change is the same write. */\n commit?: CommitScope;\n labels?: LabelOverride<FeedbackPageLabels>;\n}) {\n const words = useFeedbackPageLabels(labels);\n const statusNames = useFeedbackStatusLabels();\n const categoryNames = useFeedbackCategoryLabels();\n const { rows, pending, setPublic } = bulk;\n const busy = pending !== null;\n const empty = rows.length === 0;\n const desktop = useBreakpoint(\"md\");\n const sends = (field: BulkField, value: FeedbackStatus | FeedbackCategory | boolean) =>\n feedbackBulkTargets(rows, field, value).length > 0;\n return (\n <BulkActionBar\n count={rows.length}\n open={desktop || undefined}\n onClear={bulk.clear}\n variant={BAR_VARIANT}\n className=\"md:shrink-0\"\n >\n <BulkChoiceMenu\n label={words.bulkStatus}\n icon={CircleDot}\n empty={empty}\n busy={busy}\n running={pending === \"status\"}\n commit={commit}\n choices={FEEDBACK_STATUS_ORDER.map((status) => ({\n key: status,\n label: statusNames[status],\n icon: FEEDBACK_STATUS_META[status].icon,\n sends: sends(\"status\", status),\n pick: () => bulk.setStatus(status),\n }))}\n />\n <BulkChoiceMenu\n label={words.bulkCategory}\n icon={Tag}\n empty={empty}\n busy={busy}\n running={pending === \"category\"}\n commit={commit}\n choices={MOVABLE_CATEGORIES.map((category) => ({\n key: category,\n label: categoryNames[category],\n icon: FEEDBACK_CATEGORY_META[category].icon,\n sends: sends(\"category\", category),\n pick: () => bulk.setCategory(category),\n }))}\n />\n {setPublic && (\n <BulkChoiceMenu\n label={words.bulkVisibility}\n icon={Eye}\n empty={empty}\n busy={busy}\n running={pending === \"public\"}\n commit={commit}\n choices={[\n {\n key: \"on\",\n label: words.bulkPublicOn,\n icon: Eye,\n sends: sends(\"public\", true),\n pick: () => setPublic(true),\n },\n {\n key: \"off\",\n label: words.bulkPublicOff,\n icon: EyeOff,\n sends: sends(\"public\", false),\n pick: () => setPublic(false),\n },\n ]}\n />\n )}\n </BulkActionBar>\n );\n}\n"],"mappings":"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","names":[]}
@@ -9,7 +9,7 @@ import { type FeedbackDetailPatch, type FeedbackRowDetailProps, type FeedbackUpl
9
9
  import { type FeedbackStatusChange } from "./feedback-status-undo.js";
10
10
  import { type FeedbackSwipeBinding } from "./feedback-swipe.js";
11
11
  import { type FeedbackPageLabels, type FeedbackRenderDate } from "./feedback-table.js";
12
- import type { FeedbackStatus } from "./feedback-vocabulary.js";
12
+ import type { FeedbackCategory, FeedbackStatus } from "./feedback-vocabulary.js";
13
13
  /**
14
14
  * The feedback inbox, ASSEMBLED ONCE (0.55; kastlan reuse audit 2026-11-02, proposal 2;
15
15
  * docs/feedback-harmonization.md §13).
@@ -37,6 +37,11 @@ import type { FeedbackStatus } from "./feedback-vocabulary.js";
37
37
  *
38
38
  * Additive: every part it is built from stays exported and unchanged, for a page that
39
39
  * wants its own layout — call the hook and spread `inbox.table` into a `DataTable`.
40
+ *
41
+ * 0.57 (keksdose dev #663): the admin's multi-select and bulk edit — the table's boxes (on
42
+ * the phone, the long press), and a bulk bar that sets the selected reports' status,
43
+ * category or visibility through the same `update`, one PATCH per report, with one Undo
44
+ * (feedback-inbox-bulk.tsx, §13.6).
40
45
  */
41
46
  /**
42
47
  * One of the page's mutations, as the inbox calls it: **TanStack Query's `useMutation`
@@ -166,6 +171,41 @@ export interface UseFeedbackInboxOptions<T extends FeedbackRecord> {
166
171
  /** Over the `feedbackPage` namespace: the columns, the card, the empty state, the toggle
167
172
  * and the title. Keep it stable: it is a memo's dependency. */
168
173
  labels?: LabelOverride<FeedbackPageLabels>;
174
+ /**
175
+ * The admin's multi-select and bulk edit (0.57, keksdose dev #663; §13.6): a box per row
176
+ * and a select-all on the desktop, the long press on the phone (swipes rest while
177
+ * selecting), and a bulk bar that sets the selected reports' status, category or — with
178
+ * {@link publicSwitch} — visibility through {@link update}, one PATCH per report, with
179
+ * one Undo. **On** wherever the inbox is editable (the admin, not `/my-feedback`);
180
+ * `false` for none. Nothing to wire: it is the page's own mutation.
181
+ */
182
+ selectable?: boolean;
183
+ }
184
+ /**
185
+ * The admin's selection and its bulk changes (0.57, keksdose dev #663; §13.6) —
186
+ * `inbox.bulk`, which {@link FeedbackInbox} draws as its bulk bar (feedback-inbox-bulk.tsx).
187
+ * For a page that lays the parts out itself, what its own `BulkActionBar` needs. Every
188
+ * change is {@link UseFeedbackInboxOptions.update}, once per report.
189
+ */
190
+ export interface FeedbackInboxBulk<T extends FeedbackRecord> {
191
+ /** The selected reports the admin can SEE — the table's filters and the awaiting toggle
192
+ * applied, in the order the inbox lists them: what the bar counts and a change acts
193
+ * on. A selected row a filter hides drops out of the selection. */
194
+ rows: readonly T[];
195
+ /** Empty the selection (the bar's Clear). */
196
+ clear: () => void;
197
+ /** Set every selected report's status: one `update` per report not there yet, one Undo
198
+ * toast for those that changed — each back to its own status. Resolves when every
199
+ * PATCH has answered; a report whose PATCH failed stays selected. */
200
+ setStatus: (status: FeedbackStatus) => Promise<void>;
201
+ /** The same for the category. Never CRASH (§3.4: a 422) — ignored. */
202
+ setCategory: (category: FeedbackCategory) => Promise<void>;
203
+ /** The same for **Visible to others** — with {@link UseFeedbackInboxOptions.publicSwitch}
204
+ * only. A CRASH and a row without `public` are left as they are (§9.3). */
205
+ setPublic?: (next: boolean) => Promise<void>;
206
+ /** The bulk change in flight, or `null`. One at a time: a press while one is out is
207
+ * ignored. */
208
+ pending: "status" | "category" | "public" | null;
169
209
  }
170
210
  /** What {@link useFeedbackInbox} returns: the state, and the props of the parts. */
171
211
  export interface FeedbackInboxState<T extends FeedbackRecord> {
@@ -194,8 +234,12 @@ export interface FeedbackInboxState<T extends FeedbackRecord> {
194
234
  /** `DataTable`'s props, ready to spread: the rows (newest first, narrowed by
195
235
  * `awaiting`), the columns, the open row, the detail, the phone's card, day headers and
196
236
  * swipes, the desktop's actions column, the empty state, `urlSync`,
197
- * `storageKey="feedback"`, `fillHeight`. */
237
+ * `storageKey="feedback"`, `fillHeight` — and, with {@link bulk}, the `selection`. */
198
238
  table: DataTableProps<T>;
239
+ /** 0.57: the selection and the bulk changes — present where the inbox is editable and
240
+ * `selectable` (the default). {@link FeedbackInbox} draws them as its bulk bar; a page
241
+ * of its own draws a `BulkActionBar` from them. */
242
+ bulk?: FeedbackInboxBulk<T>;
199
243
  }
200
244
  /**
201
245
  * The inbox's wiring, once (§13): from the app's rows and mutations to what the table,
@@ -211,7 +255,7 @@ export interface FeedbackInboxState<T extends FeedbackRecord> {
211
255
  * {@link FeedbackInbox} is this hook drawn — use the hook alone for a layout of your own.
212
256
  * Needs a react-router Router (`?row=`, `urlSync`).
213
257
  */
214
- export declare function useFeedbackInbox<T extends FeedbackRecord>({ rows, mine, canEdit, viewerId, update, reply, rework, publicSwitch, votes, comments, renderDetail, columns: ownColumns, swipe, commit, onUpload, fetcher, onCaptureScreenshot, renderDate: renderDateProp, pageLinkExclude, loading, detail, labels, }: UseFeedbackInboxOptions<T>): FeedbackInboxState<T>;
258
+ export declare function useFeedbackInbox<T extends FeedbackRecord>({ rows, mine, canEdit, viewerId, update, reply, rework, publicSwitch, votes, comments, renderDetail, columns: ownColumns, swipe, commit, onUpload, fetcher, onCaptureScreenshot, renderDate: renderDateProp, pageLinkExclude, loading, detail, labels, selectable, }: UseFeedbackInboxOptions<T>): FeedbackInboxState<T>;
215
259
  export interface FeedbackInboxProps<T extends FeedbackRecord> extends UseFeedbackInboxOptions<T> {
216
260
  /**
217
261
  * Above the table. Default: `<PageHeader title={title} className="mb-4 md:shrink-0" />`.
@@ -10,6 +10,7 @@ import { PageHeader } from "../components/page-header.js";
10
10
  import { useSearchParamState } from "../hooks/use-search-param-state.js";
11
11
  import { FILL_HEIGHT_CLASS } from "../shell/fill-height.js";
12
12
  import { captureAppScreenshot } from "./feedback-capture.js";
13
+ import { FeedbackInboxBulkBar, useFeedbackInboxBulk } from "./feedback-inbox-bulk.js";
13
14
  import { useFeedbackStatusLabels } from "./feedback-labels.js";
14
15
  import { runMutation } from "./feedback-mutation.js";
15
16
  import {
@@ -66,7 +67,8 @@ function useFeedbackInbox({
66
67
  pageLinkExclude,
67
68
  loading = false,
68
69
  detail,
69
- labels
70
+ labels,
71
+ selectable = true
70
72
  }) {
71
73
  const editable = canEdit && !mine;
72
74
  const pageLabels = useFeedbackPageLabels(labels);
@@ -90,6 +92,13 @@ function useFeedbackInbox({
90
92
  () => (editable && awaiting ? rows.filter((row) => isFeedbackAwaiting(row.status)) : [...rows]).sort(newestFirst),
91
93
  [rows, editable, awaiting]
92
94
  );
95
+ const { bulk, selection } = useFeedbackInboxBulk({
96
+ on: editable && selectable,
97
+ rows: shown,
98
+ update: updateAsync,
99
+ publicSwitch,
100
+ labels
101
+ });
93
102
  const kitColumns = useFeedbackColumns({
94
103
  mine,
95
104
  canEdit,
@@ -161,7 +170,10 @@ function useFeedbackInbox({
161
170
  mobileSwipeActions: editable ? (row) => feedbackSwipePlan(swipe, row, { change: changeStatus, labels: statusLabels }) : void 0,
162
171
  // The swipes as the desktop's last column (0.46, keksdose dev#598), less what the
163
172
  // status cell already offers.
164
- desktopSwipeActions: { omit: feedbackSwipeOmitShown, name: rowTitle, ...commit === void 0 ? {} : { commit } }
173
+ desktopSwipeActions: { omit: feedbackSwipeOmitShown, name: rowTitle, ...commit === void 0 ? {} : { commit } },
174
+ // The boxes, the select-all over what the filters keep, and the phone's long press —
175
+ // in whose mode DataTable rests the swipes (§13.6).
176
+ ...selection ? { selection } : {}
165
177
  };
166
178
  return {
167
179
  editable,
@@ -174,7 +186,8 @@ function useFeedbackInbox({
174
186
  setAwaiting,
175
187
  renderDate,
176
188
  detailProps,
177
- table
189
+ table,
190
+ ...bulk ? { bulk } : {}
178
191
  };
179
192
  }
180
193
  function FeedbackInbox({
@@ -187,12 +200,14 @@ function FeedbackInbox({
187
200
  const inbox = useFeedbackInbox(options);
188
201
  const pageLabels = useFeedbackPageLabels(options.labels);
189
202
  const head = header === void 0 ? /* @__PURE__ */ jsx(PageHeader, { title: inbox.title, className: "mb-4 md:shrink-0" }) : typeof header === "function" ? header(inbox.title) : header;
203
+ const selecting = (inbox.bulk?.rows.length ?? 0) > 0;
190
204
  return (
191
205
  // A plain element on the way to the table carries the fill (page layout §1.3).
192
206
  /* @__PURE__ */ jsxs("div", { className: cn(FILL_HEIGHT_CLASS, className), "aria-busy": options.loading || void 0, "data-feedback-inbox": "", children: [
193
207
  head,
208
+ inbox.bulk && /* @__PURE__ */ jsx(FeedbackInboxBulkBar, { bulk: inbox.bulk, commit: options.commit, labels: options.labels }),
194
209
  /* @__PURE__ */ jsx(DataTable, { ...inbox.table, ...table }),
195
- inbox.editable ? (
210
+ selecting ? null : inbox.editable ? (
196
211
  // The phone's floating action (keksdose feedback #58): only on the inbox — on
197
212
  // /my-feedback the author is not waiting on their own verdict.
198
213
  /* @__PURE__ */ jsx(FeedbackAwaitingToggle, { pressed: inbox.awaiting, onPressedChange: inbox.setAwaiting, labels: options.labels, children: phoneActions })