beads-ui 0.1.1 → 0.2.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/CHANGES.md +27 -1
- package/README.md +39 -45
- package/app/data/providers.js +57 -26
- package/app/index.html +8 -0
- package/app/main.js +179 -33
- package/app/protocol.md +3 -4
- package/app/router.js +45 -9
- package/app/state.js +27 -11
- package/app/styles.css +170 -6
- package/app/utils/issue-id-renderer.js +71 -0
- package/app/utils/issue-url.js +9 -0
- package/app/utils/toast.js +35 -0
- package/app/views/board.js +347 -17
- package/app/views/detail.js +292 -92
- package/app/views/epics.js +2 -2
- package/app/views/issue-dialog.js +170 -0
- package/app/views/issue-row.js +9 -8
- package/app/views/list.js +85 -11
- package/app/views/new-issue-dialog.js +352 -0
- package/app/ws.js +30 -0
- package/docs/architecture.md +1 -1
- package/package.json +17 -1
- package/server/cli/commands.js +11 -3
- package/server/cli/index.js +35 -4
- package/server/cli/usage.js +1 -1
- package/server/watcher.js +3 -3
- package/server/ws.js +39 -19
- package/.beads/issues.jsonl +0 -107
- package/.editorconfig +0 -10
- package/.eslintrc.json +0 -36
- package/.github/workflows/ci.yml +0 -38
- package/.prettierignore +0 -5
- package/AGENTS.md +0 -85
- package/app/data/providers.test.js +0 -126
- package/app/main.board-switch.test.js +0 -94
- package/app/main.deep-link.test.js +0 -64
- package/app/main.live-updates.test.js +0 -229
- package/app/main.test.js +0 -17
- package/app/main.theme.test.js +0 -41
- package/app/main.view-sync.test.js +0 -54
- package/app/protocol.test.js +0 -57
- package/app/router.test.js +0 -34
- package/app/state.test.js +0 -21
- package/app/utils/markdown.test.js +0 -103
- package/app/utils/type-badge.test.js +0 -30
- package/app/views/board.test.js +0 -184
- package/app/views/detail.acceptance-notes.test.js +0 -67
- package/app/views/detail.assignee.test.js +0 -161
- package/app/views/detail.deps.test.js +0 -97
- package/app/views/detail.edits.test.js +0 -146
- package/app/views/detail.labels.test.js +0 -73
- package/app/views/detail.priority.test.js +0 -86
- package/app/views/detail.test.js +0 -188
- package/app/views/detail.ui47.test.js +0 -78
- package/app/views/epics.test.js +0 -283
- package/app/views/list.inline-edits.test.js +0 -84
- package/app/views/list.test.js +0 -479
- package/app/views/nav.test.js +0 -43
- package/app/ws.test.js +0 -168
- package/docs/quickstart.md +0 -142
- package/eslint.config.js +0 -59
- package/media/bdui-board.png +0 -0
- package/media/bdui-epics.png +0 -0
- package/media/bdui-issues.png +0 -0
- package/prettier.config.js +0 -13
- package/server/app.test.js +0 -29
- package/server/bd.test.js +0 -93
- package/server/cli/cli.test.js +0 -109
- package/server/cli/commands.integration.test.js +0 -155
- package/server/cli/commands.unit.test.js +0 -94
- package/server/cli/open.test.js +0 -26
- package/server/db.test.js +0 -70
- package/server/protocol.test.js +0 -87
- package/server/watcher.test.js +0 -100
- package/server/ws.handlers.test.js +0 -174
- package/server/ws.labels.test.js +0 -95
- package/server/ws.mutations.test.js +0 -261
- package/server/ws.subscriptions.test.js +0 -116
- package/server/ws.test.js +0 -52
- package/test/setup-vitest.js +0 -12
- package/tsconfig.json +0 -23
- package/vitest.config.mjs +0 -14
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Lightweight wrapper around the native <dialog> for issue details
|
|
2
|
+
import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
|
|
3
|
+
|
|
4
|
+
// Provides: open(id), close(), getMount()
|
|
5
|
+
// Ensures accessibility, backdrop click to close, and Esc handling.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {{ getState: () => { selected_id: string|null } }} Store
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create and manage the Issue Details dialog.
|
|
13
|
+
* @param {HTMLElement} mount_element - Container to attach the <dialog> to (e.g., #detail-panel)
|
|
14
|
+
* @param {Store} store - Read-only access to app state
|
|
15
|
+
* @param {() => void} onClose - Called when dialog requests close (backdrop/esc/button)
|
|
16
|
+
* @returns {{ open: (id: string) => void, close: () => void, getMount: () => HTMLElement }}
|
|
17
|
+
*/
|
|
18
|
+
export function createIssueDialog(mount_element, store, onClose) {
|
|
19
|
+
/** @type {HTMLDialogElement} */
|
|
20
|
+
const dialog = /** @type {any} */ (document.createElement('dialog'));
|
|
21
|
+
dialog.id = 'issue-dialog';
|
|
22
|
+
dialog.setAttribute('role', 'dialog');
|
|
23
|
+
dialog.setAttribute('aria-modal', 'true');
|
|
24
|
+
|
|
25
|
+
// Shell: header (id + close) + body mount
|
|
26
|
+
dialog.innerHTML = `
|
|
27
|
+
<div class="issue-dialog__container" part="container">
|
|
28
|
+
<header class="issue-dialog__header">
|
|
29
|
+
<div class="issue-dialog__title">
|
|
30
|
+
<span class="mono" id="issue-dialog-title"></span>
|
|
31
|
+
</div>
|
|
32
|
+
<button type="button" class="issue-dialog__close" aria-label="Close">×</button>
|
|
33
|
+
</header>
|
|
34
|
+
<div class="issue-dialog__body" id="issue-dialog-body"></div>
|
|
35
|
+
</div>
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
mount_element.appendChild(dialog);
|
|
39
|
+
|
|
40
|
+
/** @type {HTMLElement} */
|
|
41
|
+
const body_mount = /** @type {any} */ (
|
|
42
|
+
dialog.querySelector('#issue-dialog-body')
|
|
43
|
+
);
|
|
44
|
+
/** @type {HTMLElement} */
|
|
45
|
+
const title_el = /** @type {any} */ (
|
|
46
|
+
dialog.querySelector('#issue-dialog-title')
|
|
47
|
+
);
|
|
48
|
+
/** @type {HTMLButtonElement} */
|
|
49
|
+
const btn_close = /** @type {any} */ (
|
|
50
|
+
dialog.querySelector('.issue-dialog__close')
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {string} id
|
|
55
|
+
*/
|
|
56
|
+
function setTitle(id) {
|
|
57
|
+
// Use copyable ID renderer but keep visible text as raw id for tests/clarity
|
|
58
|
+
title_el.replaceChildren();
|
|
59
|
+
title_el.appendChild(createIssueIdRenderer(String(id || '')));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Backdrop click: when clicking the dialog itself (outside container), close
|
|
63
|
+
dialog.addEventListener('mousedown', (ev) => {
|
|
64
|
+
if (ev.target === dialog) {
|
|
65
|
+
ev.preventDefault();
|
|
66
|
+
requestClose();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
// Esc key produces a cancel event on <dialog>
|
|
70
|
+
dialog.addEventListener('cancel', (ev) => {
|
|
71
|
+
ev.preventDefault();
|
|
72
|
+
requestClose();
|
|
73
|
+
});
|
|
74
|
+
// Close button
|
|
75
|
+
btn_close.addEventListener('click', () => requestClose());
|
|
76
|
+
|
|
77
|
+
/** @type {HTMLElement | null} */
|
|
78
|
+
let last_focus = null;
|
|
79
|
+
|
|
80
|
+
function requestClose() {
|
|
81
|
+
try {
|
|
82
|
+
if (typeof dialog.close === 'function') {
|
|
83
|
+
dialog.close();
|
|
84
|
+
} else {
|
|
85
|
+
dialog.removeAttribute('open');
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
dialog.removeAttribute('open');
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
onClose();
|
|
92
|
+
} catch {
|
|
93
|
+
// ignore consumer errors
|
|
94
|
+
}
|
|
95
|
+
// Restore focus to the element that had focus before opening
|
|
96
|
+
restoreFocus();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} id
|
|
101
|
+
*/
|
|
102
|
+
function open(id) {
|
|
103
|
+
// Capture currently focused element to restore after closing
|
|
104
|
+
try {
|
|
105
|
+
const ae = /** @type {any} */ (document.activeElement);
|
|
106
|
+
if (ae && ae instanceof HTMLElement) {
|
|
107
|
+
last_focus = ae;
|
|
108
|
+
} else {
|
|
109
|
+
last_focus = null;
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
last_focus = null;
|
|
113
|
+
}
|
|
114
|
+
setTitle(id);
|
|
115
|
+
try {
|
|
116
|
+
if (
|
|
117
|
+
'showModal' in dialog &&
|
|
118
|
+
typeof (/** @type {any} */ (dialog).showModal) === 'function'
|
|
119
|
+
) {
|
|
120
|
+
/** @type {any} */ (dialog).showModal();
|
|
121
|
+
} else {
|
|
122
|
+
dialog.setAttribute('open', '');
|
|
123
|
+
}
|
|
124
|
+
// Focus the dialog container for keyboard users
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
try {
|
|
127
|
+
btn_close.focus();
|
|
128
|
+
} catch {
|
|
129
|
+
// ignore
|
|
130
|
+
}
|
|
131
|
+
}, 0);
|
|
132
|
+
} catch {
|
|
133
|
+
// Fallback for environments without <dialog>
|
|
134
|
+
dialog.setAttribute('open', '');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function close() {
|
|
139
|
+
try {
|
|
140
|
+
if (typeof dialog.close === 'function') {
|
|
141
|
+
dialog.close();
|
|
142
|
+
} else {
|
|
143
|
+
dialog.removeAttribute('open');
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
dialog.removeAttribute('open');
|
|
147
|
+
}
|
|
148
|
+
restoreFocus();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function restoreFocus() {
|
|
152
|
+
try {
|
|
153
|
+
if (last_focus && document.contains(last_focus)) {
|
|
154
|
+
last_focus.focus();
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// ignore focus errors
|
|
158
|
+
} finally {
|
|
159
|
+
last_focus = null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
open,
|
|
165
|
+
close,
|
|
166
|
+
getMount() {
|
|
167
|
+
return body_mount;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
package/app/views/issue-row.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { html } from 'lit-html';
|
|
2
|
-
import {
|
|
2
|
+
import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
|
|
3
3
|
import { emojiForPriority } from '../utils/priority-badge.js';
|
|
4
4
|
import { priority_levels } from '../utils/priority.js';
|
|
5
5
|
import { statusLabel } from '../utils/status.js';
|
|
@@ -92,9 +92,9 @@ export function createIssueRowRenderer(options) {
|
|
|
92
92
|
}
|
|
93
93
|
@keydown=${
|
|
94
94
|
/** @param {KeyboardEvent} e */ (e) => {
|
|
95
|
-
e.stopPropagation();
|
|
96
95
|
if (e.key === 'Enter') {
|
|
97
96
|
e.preventDefault();
|
|
97
|
+
e.stopPropagation();
|
|
98
98
|
editing.add(k);
|
|
99
99
|
request_render();
|
|
100
100
|
}
|
|
@@ -143,14 +143,15 @@ export function createIssueRowRenderer(options) {
|
|
|
143
143
|
const cur_prio = String(it.priority ?? 2);
|
|
144
144
|
const is_selected = get_selected_id() === it.id;
|
|
145
145
|
return html`<tr
|
|
146
|
+
role="row"
|
|
146
147
|
class="${row_class} ${is_selected ? 'selected' : ''}"
|
|
147
148
|
data-issue-id=${it.id}
|
|
148
149
|
@click=${makeRowClick(it.id)}
|
|
149
150
|
>
|
|
150
|
-
<td class="mono">${
|
|
151
|
-
<td>${createTypeBadge(it.issue_type)}</td>
|
|
152
|
-
<td>${editableText(it.id, 'title', it.title || '')}</td>
|
|
153
|
-
<td>
|
|
151
|
+
<td role="gridcell" class="mono">${createIssueIdRenderer(it.id)}</td>
|
|
152
|
+
<td role="gridcell">${createTypeBadge(it.issue_type)}</td>
|
|
153
|
+
<td role="gridcell">${editableText(it.id, 'title', it.title || '')}</td>
|
|
154
|
+
<td role="gridcell">
|
|
154
155
|
<select
|
|
155
156
|
class="badge-select badge--status is-${cur_status}"
|
|
156
157
|
.value=${cur_status}
|
|
@@ -164,10 +165,10 @@ export function createIssueRowRenderer(options) {
|
|
|
164
165
|
)}
|
|
165
166
|
</select>
|
|
166
167
|
</td>
|
|
167
|
-
<td>
|
|
168
|
+
<td role="gridcell">
|
|
168
169
|
${editableText(it.id, 'assignee', it.assignee || '', 'Unassigned')}
|
|
169
170
|
</td>
|
|
170
|
-
<td>
|
|
171
|
+
<td role="gridcell">
|
|
171
172
|
<select
|
|
172
173
|
class="badge-select badge--priority ${'is-p' + cur_prio}"
|
|
173
174
|
.value=${cur_prio}
|
package/app/views/list.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* global NodeListOf */
|
|
2
2
|
import { html, render } from 'lit-html';
|
|
3
3
|
import { ISSUE_TYPES, typeLabel } from '../utils/issue-type.js';
|
|
4
|
+
import { issueHashFor } from '../utils/issue-url.js';
|
|
4
5
|
// issueDisplayId not used directly in this file; rendered in shared row
|
|
5
6
|
import { statusLabel } from '../utils/status.js';
|
|
6
7
|
import { createIssueRowRenderer } from './issue-row.js';
|
|
@@ -36,7 +37,9 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
36
37
|
const row_renderer = createIssueRowRenderer({
|
|
37
38
|
navigate: (id) => {
|
|
38
39
|
const nav = navigate_fn || ((h) => (window.location.hash = h));
|
|
39
|
-
|
|
40
|
+
/** @type {'issues'|'epics'|'board'} */
|
|
41
|
+
const view = store ? store.getState().view : 'issues';
|
|
42
|
+
nav(issueHashFor(view, id));
|
|
40
43
|
},
|
|
41
44
|
onUpdate: updateInline,
|
|
42
45
|
requestRender: doRender,
|
|
@@ -164,7 +167,12 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
164
167
|
<div class="muted" style="padding:10px 12px;">No issues</div>
|
|
165
168
|
</div>`
|
|
166
169
|
: html`<div class="issues-block">
|
|
167
|
-
<table
|
|
170
|
+
<table
|
|
171
|
+
class="table"
|
|
172
|
+
role="grid"
|
|
173
|
+
aria-rowcount=${String(filtered.length)}
|
|
174
|
+
aria-colcount="6"
|
|
175
|
+
>
|
|
168
176
|
<colgroup>
|
|
169
177
|
<col style="width: 100px" />
|
|
170
178
|
<col style="width: 120px" />
|
|
@@ -174,16 +182,16 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
174
182
|
<col style="width: 130px" />
|
|
175
183
|
</colgroup>
|
|
176
184
|
<thead>
|
|
177
|
-
<tr>
|
|
178
|
-
<th>ID</th>
|
|
179
|
-
<th>Type</th>
|
|
180
|
-
<th>Title</th>
|
|
181
|
-
<th>Status</th>
|
|
182
|
-
<th>Assignee</th>
|
|
183
|
-
<th>Priority</th>
|
|
185
|
+
<tr role="row">
|
|
186
|
+
<th role="columnheader">ID</th>
|
|
187
|
+
<th role="columnheader">Type</th>
|
|
188
|
+
<th role="columnheader">Title</th>
|
|
189
|
+
<th role="columnheader">Status</th>
|
|
190
|
+
<th role="columnheader">Assignee</th>
|
|
191
|
+
<th role="columnheader">Priority</th>
|
|
184
192
|
</tr>
|
|
185
193
|
</thead>
|
|
186
|
-
<tbody>
|
|
194
|
+
<tbody role="rowgroup">
|
|
187
195
|
${filtered.map((it) => row_renderer(it))}
|
|
188
196
|
</tbody>
|
|
189
197
|
</table>
|
|
@@ -251,6 +259,70 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
251
259
|
// Keyboard navigation
|
|
252
260
|
mount_element.tabIndex = 0;
|
|
253
261
|
mount_element.addEventListener('keydown', (ev) => {
|
|
262
|
+
// Grid cell Up/Down navigation when focus is inside the table and not within
|
|
263
|
+
// an editable control (input/textarea/select). Preserves column position.
|
|
264
|
+
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
|
265
|
+
/** @type {any} */
|
|
266
|
+
const tgt = /** @type {any} */ (ev.target);
|
|
267
|
+
/** @type {HTMLTableElement|null} */
|
|
268
|
+
const table =
|
|
269
|
+
tgt && typeof tgt.closest === 'function'
|
|
270
|
+
? /** @type {any} */ (tgt.closest('#list-root table.table'))
|
|
271
|
+
: null;
|
|
272
|
+
if (table) {
|
|
273
|
+
// Do not intercept when inside native editable controls
|
|
274
|
+
const in_editable = Boolean(
|
|
275
|
+
tgt &&
|
|
276
|
+
typeof tgt.closest === 'function' &&
|
|
277
|
+
(tgt.closest('input') ||
|
|
278
|
+
tgt.closest('textarea') ||
|
|
279
|
+
tgt.closest('select'))
|
|
280
|
+
);
|
|
281
|
+
if (!in_editable) {
|
|
282
|
+
/** @type {HTMLTableCellElement|null} */
|
|
283
|
+
const cell =
|
|
284
|
+
tgt && typeof tgt.closest === 'function'
|
|
285
|
+
? /** @type {any} */ (tgt.closest('td'))
|
|
286
|
+
: null;
|
|
287
|
+
if (cell && cell.parentElement) {
|
|
288
|
+
/** @type {HTMLTableRowElement} */
|
|
289
|
+
const row = /** @type {any} */ (cell.parentElement);
|
|
290
|
+
/** @type {HTMLTableSectionElement|null} */
|
|
291
|
+
const tbody = /** @type {any} */ (row.parentElement);
|
|
292
|
+
if (tbody && tbody.querySelectorAll) {
|
|
293
|
+
const rows = Array.from(tbody.querySelectorAll('tr'));
|
|
294
|
+
const row_idx = Math.max(0, rows.indexOf(row));
|
|
295
|
+
const col_idx = /** @type {any} */ (cell).cellIndex || 0;
|
|
296
|
+
const next_idx =
|
|
297
|
+
ev.key === 'ArrowDown'
|
|
298
|
+
? Math.min(row_idx + 1, rows.length - 1)
|
|
299
|
+
: Math.max(row_idx - 1, 0);
|
|
300
|
+
const next_row = /** @type {HTMLTableRowElement} */ (
|
|
301
|
+
rows[next_idx]
|
|
302
|
+
);
|
|
303
|
+
/** @type {HTMLTableCellElement|null} */
|
|
304
|
+
const next_cell = /** @type {any} */ (
|
|
305
|
+
next_row && next_row.cells ? next_row.cells[col_idx] : null
|
|
306
|
+
);
|
|
307
|
+
if (next_cell) {
|
|
308
|
+
/** @type {HTMLElement|null} */
|
|
309
|
+
const focusable = /** @type {any} */ (
|
|
310
|
+
next_cell.querySelector(
|
|
311
|
+
'button:not([disabled]), [tabindex]:not([tabindex="-1"]), a[href], select:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled])'
|
|
312
|
+
)
|
|
313
|
+
);
|
|
314
|
+
if (focusable && typeof focusable.focus === 'function') {
|
|
315
|
+
ev.preventDefault();
|
|
316
|
+
focusable.focus();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
254
326
|
/** @type {HTMLTableSectionElement|null} */
|
|
255
327
|
const tbody = /** @type {any} */ (
|
|
256
328
|
mount_element.querySelector('#list-root tbody')
|
|
@@ -299,7 +371,9 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
299
371
|
const id = current ? current.getAttribute('data-issue-id') : '';
|
|
300
372
|
if (id) {
|
|
301
373
|
const nav = navigate_fn || ((h) => (window.location.hash = h));
|
|
302
|
-
|
|
374
|
+
/** @type {'issues'|'epics'|'board'} */
|
|
375
|
+
const view = store ? store.getState().view : 'issues';
|
|
376
|
+
nav(issueHashFor(view, id));
|
|
303
377
|
}
|
|
304
378
|
}
|
|
305
379
|
});
|