@sigrun/tui 0.1.0 → 0.1.1

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 (84) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/actions.d.ts +43 -2
  3. package/dist/api.d.ts +5 -0
  4. package/dist/api.js +5 -1
  5. package/dist/blocks/braille.d.ts +41 -0
  6. package/dist/blocks/braille.js +131 -0
  7. package/dist/blocks/chart.d.ts +20 -0
  8. package/dist/blocks/chart.js +397 -0
  9. package/dist/blocks/collection/calendar.d.ts +28 -0
  10. package/dist/blocks/collection/calendar.js +232 -0
  11. package/dist/blocks/collection/card.d.ts +36 -0
  12. package/dist/blocks/collection/card.js +128 -0
  13. package/dist/blocks/collection/collection.d.ts +9 -0
  14. package/dist/blocks/collection/collection.js +26 -0
  15. package/dist/blocks/collection/grid.d.ts +18 -0
  16. package/dist/blocks/collection/grid.js +72 -0
  17. package/dist/blocks/collection/lanes.d.ts +32 -0
  18. package/dist/blocks/collection/lanes.js +320 -0
  19. package/dist/blocks/collection/list.d.ts +30 -0
  20. package/dist/blocks/collection/list.js +273 -0
  21. package/dist/blocks/collection/move.d.ts +67 -0
  22. package/dist/blocks/collection/move.js +96 -0
  23. package/dist/blocks/collection/shared.d.ts +87 -0
  24. package/dist/blocks/collection/shared.js +177 -0
  25. package/dist/blocks/collection/table.d.ts +26 -0
  26. package/dist/blocks/collection/table.js +221 -0
  27. package/dist/blocks/content.d.ts +48 -0
  28. package/dist/blocks/content.js +253 -0
  29. package/dist/blocks/context.d.ts +96 -0
  30. package/dist/blocks/context.js +65 -0
  31. package/dist/blocks/diff.d.ts +21 -0
  32. package/dist/blocks/diff.js +25 -0
  33. package/dist/blocks/ext-transport.d.ts +70 -0
  34. package/dist/blocks/ext-transport.js +165 -0
  35. package/dist/blocks/form/choice.d.ts +54 -0
  36. package/dist/blocks/form/choice.js +175 -0
  37. package/dist/blocks/form/fields.d.ts +56 -0
  38. package/dist/blocks/form/fields.js +136 -0
  39. package/dist/blocks/form/file.d.ts +61 -0
  40. package/dist/blocks/form/file.js +121 -0
  41. package/dist/blocks/form/form.d.ts +53 -0
  42. package/dist/blocks/form/form.js +703 -0
  43. package/dist/blocks/form/list.d.ts +37 -0
  44. package/dist/blocks/form/list.js +70 -0
  45. package/dist/blocks/form/values.d.ts +20 -0
  46. package/dist/blocks/form/values.js +104 -0
  47. package/dist/blocks/icons.d.ts +14 -0
  48. package/dist/blocks/icons.js +74 -0
  49. package/dist/blocks/image.d.ts +39 -0
  50. package/dist/blocks/image.js +82 -0
  51. package/dist/blocks/layout.d.ts +31 -0
  52. package/dist/blocks/layout.js +155 -0
  53. package/dist/blocks/log.d.ts +24 -0
  54. package/dist/blocks/log.js +111 -0
  55. package/dist/blocks/meter.d.ts +16 -0
  56. package/dist/blocks/meter.js +50 -0
  57. package/dist/blocks/page.d.ts +54 -0
  58. package/dist/blocks/page.js +355 -0
  59. package/dist/blocks/render.d.ts +31 -0
  60. package/dist/blocks/render.js +145 -0
  61. package/dist/blocks/surface-view.d.ts +33 -0
  62. package/dist/blocks/surface-view.js +86 -0
  63. package/dist/blocks/surface.d.ts +75 -0
  64. package/dist/blocks/surface.js +468 -0
  65. package/dist/blocks/tones.d.ts +23 -0
  66. package/dist/blocks/tones.js +63 -0
  67. package/dist/blocks/treeview.d.ts +22 -0
  68. package/dist/blocks/treeview.js +121 -0
  69. package/dist/blocks/width.d.ts +32 -0
  70. package/dist/blocks/width.js +70 -0
  71. package/dist/keymaps/emacs.js +39 -0
  72. package/dist/keymaps/standard.js +45 -1
  73. package/dist/keymaps/vim.js +37 -0
  74. package/dist/launch.js +13 -0
  75. package/dist/sse.d.ts +8 -0
  76. package/dist/sse.js +27 -0
  77. package/dist/theme.d.ts +2 -2
  78. package/dist/theme.js +16 -2
  79. package/dist/ui/app.d.ts +36 -0
  80. package/dist/ui/app.js +84 -0
  81. package/dist/ui/interactions.js +2 -7
  82. package/dist/ui/sidebar.d.ts +25 -2
  83. package/dist/ui/sidebar.js +64 -0
  84. package/package.json +3 -3
@@ -0,0 +1,273 @@
1
+ /**
2
+ * The `list` display (UI-blocks spec §5.5): rows with group headings in
3
+ * `groups` order, 2-cell-per-level nesting on `children`, `numbered`,
4
+ * checkboxes, quiet items folded behind "N more", and the multi-select
5
+ * bulk action menu (`.`).
6
+ *
7
+ * Keys (spec §12.4): Up/Down move the cursor; `x` toggles the checkable
8
+ * field and dispatches `onCheck` (`edit`, `{itemId, value}`); `Space`
9
+ * selects; `.` opens the bulk menu; Enter activates a row (`onActivate`
10
+ * with `{itemId}`), unfolds the "N more" fold, or picks a menu entry.
11
+ * Selection, folds and menus are view-only — they never dispatch.
12
+ *
13
+ * The bulk menu is the seam Task 5's form renderer plugs into: an
14
+ * action with a `form` opens a minimal seam form here (labeled line
15
+ * per top-level field name, values keyed by name) and its submit rides
16
+ * the bulk payload as `{ids, values}` — mirroring U1 Task 3's note.
17
+ */
18
+ import { bindText } from "../context.js";
19
+ import { truncate, widthClassOf } from "../width.js";
20
+ import { cardLines } from "./card.js";
21
+ import { CollectionState, childItems, collectionItems, dispatchActivate, dispatchBulk, dispatchCheck, groupItems, isQuiet, sortItems, } from "./shared.js";
22
+ /** Top-level `form` block fields of a bulk action's form tree. */
23
+ function seamFields(form, ctx) {
24
+ const root = form.root;
25
+ if (root.type !== "form")
26
+ return [];
27
+ const fields = [];
28
+ for (const child of root.children) {
29
+ if (child.type === "section")
30
+ continue;
31
+ fields.push({ name: child.name, label: bindText(child.label, ctx) || child.name });
32
+ }
33
+ return fields;
34
+ }
35
+ /** `list`: groups, nesting, numbering, checkboxes, quiet fold, bulk menu. */
36
+ export class ListComponent {
37
+ #block;
38
+ #ctx;
39
+ #state = new CollectionState();
40
+ #tick = 0;
41
+ constructor(block, ctx) {
42
+ this.#block = block;
43
+ this.#ctx = ctx;
44
+ }
45
+ /** The items in render order (block's initial `sort`). */
46
+ #items() {
47
+ return sortItems(collectionItems(this.#block, this.#ctx), this.#block.sort);
48
+ }
49
+ /** Flatten the render into addressable rows. */
50
+ #rows() {
51
+ const rows = [];
52
+ const push = (row, depth) => {
53
+ rows.push({ kind: "item", row, depth });
54
+ for (const child of childItems(this.#block, row.item))
55
+ push(child, depth + 1);
56
+ };
57
+ for (const group of this.#groups()) {
58
+ if (group.id !== "" && group.title !== "") {
59
+ rows.push({ kind: "group", groupId: group.id });
60
+ }
61
+ if (group.collapsed && !this.#state.unfoldedGroups.has(group.id))
62
+ continue;
63
+ const quiet = group.items.filter((row) => isQuiet(this.#block, this.#ctx, row.item));
64
+ const loud = group.items.filter((row) => !isQuiet(this.#block, this.#ctx, row.item));
65
+ for (const row of loud)
66
+ push(row, 0);
67
+ if (quiet.length > 0) {
68
+ if (this.#state.quietShownFor(group.id)) {
69
+ for (const row of quiet)
70
+ push(row, 0);
71
+ rows.push({ kind: "quiet", groupId: group.id, quietRows: [] }); // the "Show less" row
72
+ }
73
+ else {
74
+ rows.push({ kind: "quiet", groupId: group.id, quietRows: quiet });
75
+ }
76
+ }
77
+ }
78
+ return rows;
79
+ }
80
+ #groups() {
81
+ return groupItems(this.#block, this.#items(), this.#ctx);
82
+ }
83
+ render(width) {
84
+ this.#tick += 1;
85
+ const rows = this.#rows();
86
+ if (this.#state.cursor >= rows.length)
87
+ this.#state.cursor = Math.max(0, rows.length - 1);
88
+ const lines = [];
89
+ const unicode = this.#ctx.unicode;
90
+ if (this.#state.menu?.kind === "bulk") {
91
+ lines.push(truncate(`Bulk actions (${this.#state.selected.size} selected)`, width));
92
+ const actions = this.#block.bulkActions ?? [];
93
+ actions.forEach((action, index) => {
94
+ const cursor = index === this.#state.menuCursor ? "> " : " ";
95
+ lines.push(truncate(`${cursor}${bindText(action.label, this.#ctx) || action.id}`, width));
96
+ });
97
+ return lines;
98
+ }
99
+ if (this.#state.menu?.kind === "form") {
100
+ const menu = this.#state.menu;
101
+ const value = this.#state.formBuffer === "" ? "…" : this.#state.formBuffer;
102
+ for (const field of seamFields(menu.form, this.#ctx)) {
103
+ lines.push(truncate(`${field.label}: ${value}`, width));
104
+ }
105
+ lines.push(truncate("Enter to submit · Esc to cancel", width));
106
+ return lines;
107
+ }
108
+ rows.forEach((row, index) => {
109
+ const cursor = index === this.#state.cursor ? "> " : " ";
110
+ if (row.kind === "group") {
111
+ lines.push(truncate(cursor + this.#ctx.theme.bold(`## ${this.#groupTitle(row.groupId ?? "")}`), width));
112
+ return;
113
+ }
114
+ if (row.kind === "quiet") {
115
+ const count = row.quietRows?.length ?? 0;
116
+ const label = this.#state.quietShownFor(row.groupId ?? "") ? "Show less" : `${count} more`;
117
+ lines.push(truncate(cursor + this.#ctx.theme.dim(label), width));
118
+ return;
119
+ }
120
+ const item = row.row;
121
+ if (item === undefined)
122
+ return;
123
+ let prefix = "";
124
+ const indentStr = " ".repeat(row.depth ?? 0);
125
+ if (this.#block.numbered === true && (row.depth ?? 0) === 0) {
126
+ prefix += `${this.#numberOf(rows, index)}. `;
127
+ }
128
+ if (this.#block.checkable !== undefined) {
129
+ prefix += this.#state.checkedValue(this.#block, item) ? "[x] " : "[ ] ";
130
+ }
131
+ if (this.#block.select !== undefined) {
132
+ const box = this.#state.selected.has(item.id)
133
+ ? unicode
134
+ ? "(•) "
135
+ : "(*) "
136
+ : "( ) ";
137
+ prefix += box;
138
+ }
139
+ const card = cardLines(this.#block.item, item.item, this.#ctx, width - cursor.length - indentStr.length - prefix.length, 0, this.#tick);
140
+ // The first card line carries the prefixes (title line by slot
141
+ // order — an eyebrow hoists the prefixes onto the eyebrow line,
142
+ // keeping one fixed line layout).
143
+ const [first, ...rest] = card;
144
+ lines.push(truncate(`${cursor}${indentStr}${prefix}${first ?? ""}`, width));
145
+ for (const line of rest) {
146
+ lines.push(truncate(`${cursor}${indentStr}${" ".repeat(prefix.length)}${line}`, width));
147
+ }
148
+ });
149
+ return lines;
150
+ }
151
+ #groupTitle(id) {
152
+ return this.#groups().find((group) => group.id === id)?.title ?? id;
153
+ }
154
+ /** The running number of a depth-0 item row (numbered lists). */
155
+ #numberOf(rows, at) {
156
+ let number = 0;
157
+ for (let i = 0; i <= at; i++) {
158
+ const row = rows[i];
159
+ if (row?.kind === "item" && row.depth === 0)
160
+ number += 1;
161
+ }
162
+ return number;
163
+ }
164
+ handleInput(data) {
165
+ if (this.#state.menu !== null) {
166
+ this.#menuInput(data);
167
+ return;
168
+ }
169
+ const rows = this.#rows();
170
+ if (data === "\x1b[B") {
171
+ this.#state.cursor = Math.min(rows.length - 1, this.#state.cursor + 1);
172
+ return;
173
+ }
174
+ if (data === "\x1b[A") {
175
+ this.#state.cursor = Math.max(0, this.#state.cursor - 1);
176
+ return;
177
+ }
178
+ const row = rows[this.#state.cursor];
179
+ if (row === undefined)
180
+ return;
181
+ if (data === "x" && row.kind === "item" && row.row !== undefined) {
182
+ dispatchCheck(this.#block, this.#ctx, this.#state, row.row);
183
+ return;
184
+ }
185
+ if (data === " " && this.#block.select !== undefined && row.kind === "item" && row.row !== undefined) {
186
+ const id = row.row.id;
187
+ if (this.#state.selected.has(id))
188
+ this.#state.selected.delete(id);
189
+ else
190
+ this.#state.selected.add(id);
191
+ return;
192
+ }
193
+ if (data === "." && this.#block.select === "multi" && this.#state.selected.size > 0) {
194
+ if ((this.#block.bulkActions ?? []).length > 0) {
195
+ this.#state.menu = { kind: "bulk" };
196
+ this.#state.menuCursor = 0;
197
+ }
198
+ return;
199
+ }
200
+ if (data !== "\r")
201
+ return;
202
+ if (row.kind === "quiet") {
203
+ this.#state.toggleQuiet(row.groupId ?? "");
204
+ return;
205
+ }
206
+ if (row.kind === "group" && row.groupId !== undefined) {
207
+ if (this.#state.unfoldedGroups.has(row.groupId)) {
208
+ this.#state.unfoldedGroups.delete(row.groupId);
209
+ }
210
+ else {
211
+ this.#state.unfoldedGroups.add(row.groupId);
212
+ }
213
+ return;
214
+ }
215
+ if (row.row !== undefined)
216
+ dispatchActivate(this.#block, this.#ctx, row.row);
217
+ }
218
+ #menuInput(data) {
219
+ const menu = this.#state.menu;
220
+ if (menu === null)
221
+ return;
222
+ if (data === "\x1b") {
223
+ this.#state.menu = null;
224
+ return;
225
+ }
226
+ if (menu.kind === "form") {
227
+ if (data === "\r") {
228
+ const values = {};
229
+ for (const field of seamFields(menu.form, this.#ctx)) {
230
+ values[field.name] = this.#state.formBuffer;
231
+ }
232
+ this.#state.formBuffer = "";
233
+ dispatchBulk(this.#ctx, this.#state, menu.onSubmit, values);
234
+ return;
235
+ }
236
+ if (data === "\x7f") {
237
+ this.#state.formBuffer = this.#state.formBuffer.slice(0, -1);
238
+ return;
239
+ }
240
+ if (!data.startsWith("\x1b") && data.length >= 1) {
241
+ this.#state.formBuffer += data;
242
+ }
243
+ return;
244
+ }
245
+ const actions = this.#block.bulkActions ?? [];
246
+ if (data === "\x1b[B") {
247
+ this.#state.menuCursor = Math.min(actions.length - 1, this.#state.menuCursor + 1);
248
+ return;
249
+ }
250
+ if (data === "\x1b[A") {
251
+ this.#state.menuCursor = Math.max(0, this.#state.menuCursor - 1);
252
+ return;
253
+ }
254
+ if (data !== "\r")
255
+ return;
256
+ const action = actions[this.#state.menuCursor];
257
+ if (action === undefined)
258
+ return;
259
+ if (action.form !== undefined) {
260
+ // The Task-5 seam: the form's full rendering lands with the
261
+ // form block; this menu opens the seam form and its values
262
+ // ride the payload (mirroring U1 Task 3's note in rows.tsx).
263
+ this.#state.menu = { kind: "form", form: action.form, onSubmit: action.onSubmit };
264
+ this.#state.formBuffer = "";
265
+ return;
266
+ }
267
+ dispatchBulk(this.#ctx, this.#state, action.onSubmit);
268
+ }
269
+ invalidate() {
270
+ // State survives re-renders (view-only by design).
271
+ }
272
+ }
273
+ export { widthClassOf };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Keyboard moves (UI-blocks U2 Task 4, spec §12.4): the shared
3
+ * placement machinery the lanes and calendar displays drive. `m` picks
4
+ * the item under the cursor up, arrows move the placement (left/right
5
+ * across groups, up/down within), Enter drops — dispatching
6
+ * `{itemId, from: {group, index}, to: {group, index}}`, the EXACT
7
+ * payload U1's pointer drop builds — and Esc cancels.
8
+ *
9
+ * The held item counts as PRESENT in the lane sizing (its removal
10
+ * happens on drop, not on pickup) — the same rule as U1's lanes. A
11
+ * dropped placement equal to the item's current one dispatches nothing.
12
+ *
13
+ * Review Focus 1: a resize while picked up is handled by the DISPLAYS,
14
+ * which recompute placements from the live block data on every key; the
15
+ * held state survives because it lives in the component, not the
16
+ * rendered lines.
17
+ */
18
+ import type { CollectionBlock, JsonObject } from "@sigrun/shared/blocks";
19
+ import { type CollectionItem, type ItemGroup } from "./shared.js";
20
+ import type { BlockCtx } from "../context.js";
21
+ /** A placement: a group plus the index within its VISIBLE items. */
22
+ export interface Placement {
23
+ readonly group: string;
24
+ readonly index: number;
25
+ }
26
+ /** The keyboard-move session (the picked-up item and its placement). */
27
+ export interface MoveState {
28
+ readonly itemId: string;
29
+ readonly from: Placement;
30
+ readonly at: Placement;
31
+ }
32
+ /** The visible items of a group (sorted, loud items — quiet items are
33
+ * folded behind the fold row and not placement targets). */
34
+ export declare function visibleLaneItems(block: CollectionBlock, ctx: BlockCtx, group: ItemGroup): CollectionItem[];
35
+ /** Every group with its visible items, in group order. */
36
+ export declare function lanes(block: CollectionBlock, ctx: BlockCtx): {
37
+ groups: ItemGroup[];
38
+ visible: Map<string, CollectionItem[]>;
39
+ };
40
+ /** An item's current placement across every group. */
41
+ export declare function placementOf(groups: readonly ItemGroup[], visible: ReadonlyMap<string, CollectionItem[]>, itemId: string): Placement | undefined;
42
+ /** The move payload (spec §6.3 step 2; U1's pointer-drop shape exactly). */
43
+ export declare function movePayload(move: {
44
+ itemId: string;
45
+ from: Placement;
46
+ to: Placement;
47
+ }): JsonObject;
48
+ /** Move the placement left/right across groups (the index clamps to
49
+ * the target lane's size; out-of-range directions are inert). */
50
+ export declare function shiftAcross(move: MoveState, groups: readonly ItemGroup[], visible: ReadonlyMap<string, CollectionItem[]>, delta: 1 | -1): MoveState;
51
+ /** Move the placement up/down within its group (clamped). */
52
+ export declare function shiftWithin(move: MoveState, visible: ReadonlyMap<string, CollectionItem[]>, delta: 1 | -1): MoveState;
53
+ /**
54
+ * Dispatch the drop: nothing when the placement equals the current
55
+ * position; otherwise the `move` action with the U1 payload. The
56
+ * optimistic flip lives in SurfaceState's overlay — the component
57
+ * itself keeps no per-item move override.
58
+ */
59
+ export declare function dispatchMove(block: CollectionBlock, ctx: BlockCtx, move: MoveState): void;
60
+ /** The per-display move session state (held in the component). */
61
+ export declare class MoveSession {
62
+ held: MoveState | undefined;
63
+ /** Pick up the item (undefined placement = no pickup). */
64
+ pickUp(itemId: string, from: Placement): void;
65
+ /** Cancel (Esc): drop the session without dispatching. */
66
+ cancel(): void;
67
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Keyboard moves (UI-blocks U2 Task 4, spec §12.4): the shared
3
+ * placement machinery the lanes and calendar displays drive. `m` picks
4
+ * the item under the cursor up, arrows move the placement (left/right
5
+ * across groups, up/down within), Enter drops — dispatching
6
+ * `{itemId, from: {group, index}, to: {group, index}}`, the EXACT
7
+ * payload U1's pointer drop builds — and Esc cancels.
8
+ *
9
+ * The held item counts as PRESENT in the lane sizing (its removal
10
+ * happens on drop, not on pickup) — the same rule as U1's lanes. A
11
+ * dropped placement equal to the item's current one dispatches nothing.
12
+ *
13
+ * Review Focus 1: a resize while picked up is handled by the DISPLAYS,
14
+ * which recompute placements from the live block data on every key; the
15
+ * held state survives because it lives in the component, not the
16
+ * rendered lines.
17
+ */
18
+ import { collectionItems, groupItems, isQuiet, sortItems, } from "./shared.js";
19
+ /** The visible items of a group (sorted, loud items — quiet items are
20
+ * folded behind the fold row and not placement targets). */
21
+ export function visibleLaneItems(block, ctx, group) {
22
+ const sorted = sortItems(group.items, block.sort);
23
+ return sorted.filter((row) => !isQuiet(block, ctx, row.item));
24
+ }
25
+ /** Every group with its visible items, in group order. */
26
+ export function lanes(block, ctx) {
27
+ const groups = groupItems(block, sortItems(collectionItems(block, ctx), block.sort), ctx);
28
+ const visible = new Map();
29
+ for (const group of groups) {
30
+ visible.set(group.id, visibleLaneItems(block, ctx, group));
31
+ }
32
+ return { groups, visible };
33
+ }
34
+ /** An item's current placement across every group. */
35
+ export function placementOf(groups, visible, itemId) {
36
+ for (const group of groups) {
37
+ const items = visible.get(group.id) ?? [];
38
+ const index = items.findIndex((row) => row.id === itemId);
39
+ if (index >= 0)
40
+ return { group: group.id, index };
41
+ }
42
+ return undefined;
43
+ }
44
+ /** The move payload (spec §6.3 step 2; U1's pointer-drop shape exactly). */
45
+ export function movePayload(move) {
46
+ return {
47
+ itemId: move.itemId,
48
+ from: { group: move.from.group, index: move.from.index },
49
+ to: { group: move.to.group, index: move.to.index },
50
+ };
51
+ }
52
+ /** Move the placement left/right across groups (the index clamps to
53
+ * the target lane's size; out-of-range directions are inert). */
54
+ export function shiftAcross(move, groups, visible, delta) {
55
+ const order = groups.map((group) => group.id);
56
+ const laneIndex = order.indexOf(move.at.group);
57
+ const target = order[laneIndex + delta];
58
+ if (target === undefined)
59
+ return move;
60
+ const size = (visible.get(target) ?? []).length;
61
+ return {
62
+ ...move,
63
+ at: { group: target, index: Math.min(move.at.index, size) },
64
+ };
65
+ }
66
+ /** Move the placement up/down within its group (clamped). */
67
+ export function shiftWithin(move, visible, delta) {
68
+ const size = (visible.get(move.at.group) ?? []).length;
69
+ const index = delta === 1 ? Math.min(move.at.index + 1, size) : Math.max(move.at.index - 1, 0);
70
+ return { ...move, at: { group: move.at.group, index } };
71
+ }
72
+ /**
73
+ * Dispatch the drop: nothing when the placement equals the current
74
+ * position; otherwise the `move` action with the U1 payload. The
75
+ * optimistic flip lives in SurfaceState's overlay — the component
76
+ * itself keeps no per-item move override.
77
+ */
78
+ export function dispatchMove(block, ctx, move) {
79
+ if (block.onMove === undefined)
80
+ return;
81
+ if (move.from.group === move.at.group && move.from.index === move.at.index)
82
+ return;
83
+ ctx.dispatch({ type: "move", name: block.onMove }, movePayload({ itemId: move.itemId, from: move.from, to: move.at }));
84
+ }
85
+ /** The per-display move session state (held in the component). */
86
+ export class MoveSession {
87
+ held;
88
+ /** Pick up the item (undefined placement = no pickup). */
89
+ pickUp(itemId, from) {
90
+ this.held = { itemId, from, at: from };
91
+ }
92
+ /** Cancel (Esc): drop the session without dispatching. */
93
+ cancel() {
94
+ this.held = undefined;
95
+ }
96
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Shared collection machinery (UI-blocks spec §5.5): item resolution,
3
+ * grouping, sorting and the view-only interaction state (selection,
4
+ * folds, menu) the list/table/grid displays drive.
5
+ *
6
+ * Every interactive display owns a {@link CollectionState} — its shape
7
+ * mirrors the `/view/<blockId>/` branch Task 4's surface state will own
8
+ * (sort, page, selection, expanded, quietShown). View-only state never
9
+ * dispatches (§6.2): only `onCheck` (`edit`), `onActivate`, and bulk
10
+ * `submit` actions reach `ctx.dispatch`.
11
+ */
12
+ import type { CollectionBlock, JsonObject, JsonValue, Tree } from "@sigrun/shared/blocks";
13
+ import { type BlockCtx } from "../context.js";
14
+ /** A resolved collection row: the item and its stable id. */
15
+ export interface CollectionItem {
16
+ id: string;
17
+ item: JsonObject;
18
+ }
19
+ /** A resolved group: heading text and its items, in `groups` order. */
20
+ export interface ItemGroup {
21
+ id: string;
22
+ title: string;
23
+ collapsed: boolean;
24
+ items: CollectionItem[];
25
+ }
26
+ /** Resolve `items` to rows, dropping malformed entries. */
27
+ export declare function collectionItems(block: CollectionBlock, ctx: BlockCtx): CollectionItem[];
28
+ /** Nested child rows of an item (the `children` field's records). */
29
+ export declare function childItems(block: CollectionBlock, item: JsonObject): CollectionItem[];
30
+ /** Compare two items on `field`, ascending. */
31
+ export declare function compareItems(a: JsonObject, b: JsonObject, field: string): number;
32
+ /** Order items by a `{field, dir}` sort (stable; view state wins over the block's). */
33
+ export declare function sortItems(items: readonly CollectionItem[], sort: {
34
+ field: string;
35
+ dir: "asc" | "desc";
36
+ } | undefined): CollectionItem[];
37
+ /**
38
+ * Group items by `groupBy`: declared `groups` in declared order, then
39
+ * unseen values in first-appearance item order. With no `groupBy`,
40
+ * one anonymous group holds everything.
41
+ */
42
+ export declare function groupItems(block: CollectionBlock, items: readonly CollectionItem[], ctx: BlockCtx): ItemGroup[];
43
+ /** Is an item quiet (folded behind "N more")? */
44
+ export declare function isQuiet(block: CollectionBlock, ctx: BlockCtx, item: JsonValue): boolean;
45
+ /**
46
+ * The view-only interaction state of one collection component. The
47
+ * checkable toggle's optimistic flip is part of it: the data update
48
+ * that follows a successful `onCheck` replaces the optimistic value
49
+ * (§6.3's convention, without moves).
50
+ */
51
+ export declare class CollectionState {
52
+ cursor: number;
53
+ readonly selected: Set<string>;
54
+ readonly checkedOverrides: Map<string, boolean>;
55
+ readonly expanded: Set<string>;
56
+ readonly unfoldedGroups: Set<string>;
57
+ /** The groups whose quiet fold is open (per-group; Task 3's global
58
+ * toggle became per-group with the lanes' per-lane folds). */
59
+ readonly quietOpen: Set<string>;
60
+ /** Is the quiet fold of `groupId` open? */
61
+ quietShownFor(groupId: string): boolean;
62
+ /** Toggle the quiet fold of `groupId`. */
63
+ toggleQuiet(groupId: string): void;
64
+ /** The open overlay: the sort choice, the bulk menu, the seam form,
65
+ * or the compact lanes' "Move to…" target list (Task 4). */
66
+ menu: {
67
+ kind: "sort";
68
+ } | {
69
+ kind: "bulk";
70
+ } | {
71
+ kind: "form";
72
+ form: Tree;
73
+ onSubmit: string;
74
+ } | {
75
+ kind: "moveTo";
76
+ } | null;
77
+ menuCursor: number;
78
+ formBuffer: string;
79
+ /** The effective checked value of an item (data plus the optimistic flip). */
80
+ checkedValue(block: CollectionBlock, row: CollectionItem): boolean;
81
+ }
82
+ /** Dispatch the `onCheck` edit for an item (spec §5.5: `{itemId, value}`). */
83
+ export declare function dispatchCheck(block: CollectionBlock, ctx: BlockCtx, state: CollectionState, row: CollectionItem): void;
84
+ /** Dispatch `onActivate` for an item, payload `{itemId}`. */
85
+ export declare function dispatchActivate(block: CollectionBlock, ctx: BlockCtx, row: CollectionItem): void;
86
+ /** Dispatch a bulk action's submit for the current selection. */
87
+ export declare function dispatchBulk(ctx: BlockCtx, state: CollectionState, onSubmit: string, values?: JsonObject): void;