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.
Files changed (82) hide show
  1. package/CHANGES.md +27 -1
  2. package/README.md +39 -45
  3. package/app/data/providers.js +57 -26
  4. package/app/index.html +8 -0
  5. package/app/main.js +179 -33
  6. package/app/protocol.md +3 -4
  7. package/app/router.js +45 -9
  8. package/app/state.js +27 -11
  9. package/app/styles.css +170 -6
  10. package/app/utils/issue-id-renderer.js +71 -0
  11. package/app/utils/issue-url.js +9 -0
  12. package/app/utils/toast.js +35 -0
  13. package/app/views/board.js +347 -17
  14. package/app/views/detail.js +292 -92
  15. package/app/views/epics.js +2 -2
  16. package/app/views/issue-dialog.js +170 -0
  17. package/app/views/issue-row.js +9 -8
  18. package/app/views/list.js +85 -11
  19. package/app/views/new-issue-dialog.js +352 -0
  20. package/app/ws.js +30 -0
  21. package/docs/architecture.md +1 -1
  22. package/package.json +17 -1
  23. package/server/cli/commands.js +11 -3
  24. package/server/cli/index.js +35 -4
  25. package/server/cli/usage.js +1 -1
  26. package/server/watcher.js +3 -3
  27. package/server/ws.js +39 -19
  28. package/.beads/issues.jsonl +0 -107
  29. package/.editorconfig +0 -10
  30. package/.eslintrc.json +0 -36
  31. package/.github/workflows/ci.yml +0 -38
  32. package/.prettierignore +0 -5
  33. package/AGENTS.md +0 -85
  34. package/app/data/providers.test.js +0 -126
  35. package/app/main.board-switch.test.js +0 -94
  36. package/app/main.deep-link.test.js +0 -64
  37. package/app/main.live-updates.test.js +0 -229
  38. package/app/main.test.js +0 -17
  39. package/app/main.theme.test.js +0 -41
  40. package/app/main.view-sync.test.js +0 -54
  41. package/app/protocol.test.js +0 -57
  42. package/app/router.test.js +0 -34
  43. package/app/state.test.js +0 -21
  44. package/app/utils/markdown.test.js +0 -103
  45. package/app/utils/type-badge.test.js +0 -30
  46. package/app/views/board.test.js +0 -184
  47. package/app/views/detail.acceptance-notes.test.js +0 -67
  48. package/app/views/detail.assignee.test.js +0 -161
  49. package/app/views/detail.deps.test.js +0 -97
  50. package/app/views/detail.edits.test.js +0 -146
  51. package/app/views/detail.labels.test.js +0 -73
  52. package/app/views/detail.priority.test.js +0 -86
  53. package/app/views/detail.test.js +0 -188
  54. package/app/views/detail.ui47.test.js +0 -78
  55. package/app/views/epics.test.js +0 -283
  56. package/app/views/list.inline-edits.test.js +0 -84
  57. package/app/views/list.test.js +0 -479
  58. package/app/views/nav.test.js +0 -43
  59. package/app/ws.test.js +0 -168
  60. package/docs/quickstart.md +0 -142
  61. package/eslint.config.js +0 -59
  62. package/media/bdui-board.png +0 -0
  63. package/media/bdui-epics.png +0 -0
  64. package/media/bdui-issues.png +0 -0
  65. package/prettier.config.js +0 -13
  66. package/server/app.test.js +0 -29
  67. package/server/bd.test.js +0 -93
  68. package/server/cli/cli.test.js +0 -109
  69. package/server/cli/commands.integration.test.js +0 -155
  70. package/server/cli/commands.unit.test.js +0 -94
  71. package/server/cli/open.test.js +0 -26
  72. package/server/db.test.js +0 -70
  73. package/server/protocol.test.js +0 -87
  74. package/server/watcher.test.js +0 -100
  75. package/server/ws.handlers.test.js +0 -174
  76. package/server/ws.labels.test.js +0 -95
  77. package/server/ws.mutations.test.js +0 -261
  78. package/server/ws.subscriptions.test.js +0 -116
  79. package/server/ws.test.js +0 -52
  80. package/test/setup-vitest.js +0 -12
  81. package/tsconfig.json +0 -23
  82. 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
+ }
@@ -1,5 +1,5 @@
1
1
  import { html } from 'lit-html';
2
- import { issueDisplayId } from '../utils/issue-id.js';
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">${issueDisplayId(it.id)}</td>
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
- nav(`#/issue/${id}`);
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 class="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
- nav(`#/issue/${id}`);
374
+ /** @type {'issues'|'epics'|'board'} */
375
+ const view = store ? store.getState().view : 'issues';
376
+ nav(issueHashFor(view, id));
303
377
  }
304
378
  }
305
379
  });