beads-ui 0.1.2 → 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 +24 -2
- 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 +8 -2
- 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/docs/quickstart.md +0 -142
package/app/router.js
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
|
+
import { issueHashFor } from './utils/issue-url.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Hash-based router for tabs (issues/epics/board) and deep-linked issue ids.
|
|
3
5
|
*/
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Parse an application hash and extract the selected issue id.
|
|
9
|
+
* Supports canonical form "#/(issues|epics|board)?issue=<id>" and legacy
|
|
10
|
+
* "#/issue/<id>" which we will rewrite to the canonical form.
|
|
7
11
|
* @param {string} hash
|
|
8
12
|
* @returns {string | null}
|
|
9
13
|
*/
|
|
10
14
|
export function parseHash(hash) {
|
|
11
|
-
const
|
|
15
|
+
const h = String(hash || '');
|
|
16
|
+
// Extract the fragment sans leading '#'
|
|
17
|
+
const frag = h.startsWith('#') ? h.slice(1) : h;
|
|
18
|
+
const qIndex = frag.indexOf('?');
|
|
19
|
+
const query = qIndex >= 0 ? frag.slice(qIndex + 1) : '';
|
|
20
|
+
if (query) {
|
|
21
|
+
const params = new URLSearchParams(query);
|
|
22
|
+
const id = params.get('issue');
|
|
23
|
+
if (id) {
|
|
24
|
+
return decodeURIComponent(id);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Legacy pattern: #/issue/<id>
|
|
28
|
+
const m = /^\/issue\/([^\s?#]+)/.exec(frag);
|
|
12
29
|
return m && m[1] ? decodeURIComponent(m[1]) : null;
|
|
13
30
|
}
|
|
14
31
|
|
|
@@ -30,18 +47,26 @@ export function parseView(hash) {
|
|
|
30
47
|
}
|
|
31
48
|
|
|
32
49
|
/**
|
|
33
|
-
* Create and start the hash router.
|
|
34
50
|
* @param {{ getState: () => any, setState: (patch: any) => void }} store
|
|
35
|
-
* @returns {{ start: () => void, stop: () => void, gotoIssue: (id: string) => void, gotoView: (v: 'issues'|'epics'|'board') => void }}
|
|
36
51
|
*/
|
|
37
52
|
export function createHashRouter(store) {
|
|
38
53
|
/** @type {(ev?: HashChangeEvent) => any} */
|
|
39
54
|
const onHashChange = () => {
|
|
40
55
|
const hash = window.location.hash || '';
|
|
56
|
+
// Rewrite legacy #/issue/<id> to canonical #/issues?issue=<id>
|
|
57
|
+
const legacyMatch = /^#\/issue\/([^\s?#]+)/.exec(hash);
|
|
58
|
+
if (legacyMatch && legacyMatch[1]) {
|
|
59
|
+
const id = decodeURIComponent(legacyMatch[1]);
|
|
60
|
+
// Update state immediately for consumers expecting sync selection
|
|
61
|
+
store.setState({ selected_id: id, view: 'issues' });
|
|
62
|
+
const next = `#/issues?issue=${encodeURIComponent(id)}`;
|
|
63
|
+
if (window.location.hash !== next) {
|
|
64
|
+
window.location.hash = next;
|
|
65
|
+
return; // will trigger handler again
|
|
66
|
+
}
|
|
67
|
+
}
|
|
41
68
|
const id = parseHash(hash);
|
|
42
|
-
|
|
43
|
-
const current = store.getState ? store.getState() : { view: 'issues' };
|
|
44
|
-
const view = id ? current.view || 'issues' : parseView(hash);
|
|
69
|
+
const view = parseView(hash);
|
|
45
70
|
store.setState({ selected_id: id, view });
|
|
46
71
|
};
|
|
47
72
|
|
|
@@ -53,21 +78,32 @@ export function createHashRouter(store) {
|
|
|
53
78
|
stop() {
|
|
54
79
|
window.removeEventListener('hashchange', onHashChange);
|
|
55
80
|
},
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} id
|
|
83
|
+
*/
|
|
56
84
|
gotoIssue(id) {
|
|
57
|
-
|
|
85
|
+
// Keep current view in hash and append issue param via helper
|
|
86
|
+
const s = store.getState ? store.getState() : { view: 'issues' };
|
|
87
|
+
const view = s.view || 'issues';
|
|
88
|
+
const next = issueHashFor(view, id);
|
|
58
89
|
if (window.location.hash !== next) {
|
|
59
90
|
window.location.hash = next;
|
|
60
91
|
} else {
|
|
61
92
|
// Force state update even if hash is the same
|
|
62
|
-
store.setState({ selected_id: id, view
|
|
93
|
+
store.setState({ selected_id: id, view });
|
|
63
94
|
}
|
|
64
95
|
},
|
|
65
96
|
/**
|
|
66
97
|
* Navigate to a top-level view.
|
|
67
98
|
* @param {'issues'|'epics'|'board'} view
|
|
68
99
|
*/
|
|
100
|
+
/**
|
|
101
|
+
* @param {'issues'|'epics'|'board'} view
|
|
102
|
+
*/
|
|
69
103
|
gotoView(view) {
|
|
70
|
-
const
|
|
104
|
+
const s = store.getState ? store.getState() : { selected_id: null };
|
|
105
|
+
const id = s.selected_id;
|
|
106
|
+
const next = id ? issueHashFor(view, id) : `#/${view}`;
|
|
71
107
|
if (window.location.hash !== next) {
|
|
72
108
|
window.location.hash = next;
|
|
73
109
|
} else {
|
package/app/state.js
CHANGED
|
@@ -15,7 +15,15 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* @typedef {
|
|
18
|
+
* @typedef {'today'|'3'|'7'} ClosedFilter
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {{ closed_filter: ClosedFilter }} BoardState
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{ selected_id: string | null, view: ViewName, filters: Filters, board: BoardState }} AppState
|
|
19
27
|
*/
|
|
20
28
|
|
|
21
29
|
/**
|
|
@@ -26,15 +34,21 @@
|
|
|
26
34
|
export function createStore(initial = {}) {
|
|
27
35
|
/** @type {AppState} */
|
|
28
36
|
let state = {
|
|
29
|
-
selected_id:
|
|
30
|
-
view:
|
|
37
|
+
selected_id: initial.selected_id ?? null,
|
|
38
|
+
view: initial.view ?? 'issues',
|
|
31
39
|
filters: {
|
|
32
|
-
status:
|
|
33
|
-
search:
|
|
40
|
+
status: initial.filters?.status ?? 'all',
|
|
41
|
+
search: initial.filters?.search ?? '',
|
|
34
42
|
type:
|
|
35
|
-
typeof
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
typeof initial.filters?.type === 'string' ? initial.filters?.type : ''
|
|
44
|
+
},
|
|
45
|
+
board: {
|
|
46
|
+
closed_filter:
|
|
47
|
+
initial.board?.closed_filter === '3' ||
|
|
48
|
+
initial.board?.closed_filter === '7' ||
|
|
49
|
+
initial.board?.closed_filter === 'today'
|
|
50
|
+
? initial.board?.closed_filter
|
|
51
|
+
: 'today'
|
|
38
52
|
}
|
|
39
53
|
};
|
|
40
54
|
|
|
@@ -57,14 +71,15 @@ export function createStore(initial = {}) {
|
|
|
57
71
|
},
|
|
58
72
|
/**
|
|
59
73
|
* Update state. Nested filters can be partial.
|
|
60
|
-
* @param {{ selected_id?: string | null, filters?: Partial<Filters> }} patch
|
|
74
|
+
* @param {{ selected_id?: string | null, filters?: Partial<Filters>, board?: Partial<BoardState> }} patch
|
|
61
75
|
*/
|
|
62
76
|
setState(patch) {
|
|
63
77
|
/** @type {AppState} */
|
|
64
78
|
const next = {
|
|
65
79
|
...state,
|
|
66
80
|
...patch,
|
|
67
|
-
filters: { ...state.filters, ...(patch.filters || {}) }
|
|
81
|
+
filters: { ...state.filters, ...(patch.filters || {}) },
|
|
82
|
+
board: { ...state.board, ...(patch.board || {}) }
|
|
68
83
|
};
|
|
69
84
|
// Avoid emitting if nothing changed (shallow compare)
|
|
70
85
|
if (
|
|
@@ -72,7 +87,8 @@ export function createStore(initial = {}) {
|
|
|
72
87
|
next.view === state.view &&
|
|
73
88
|
next.filters.status === state.filters.status &&
|
|
74
89
|
next.filters.search === state.filters.search &&
|
|
75
|
-
next.filters.type === state.filters.type
|
|
90
|
+
next.filters.type === state.filters.type &&
|
|
91
|
+
next.board.closed_filter === state.board.closed_filter
|
|
76
92
|
) {
|
|
77
93
|
return;
|
|
78
94
|
}
|
package/app/styles.css
CHANGED
|
@@ -158,6 +158,7 @@ a:focus {
|
|
|
158
158
|
.header-nav .tab:hover,
|
|
159
159
|
.header-nav .tab:focus {
|
|
160
160
|
color: var(--fg);
|
|
161
|
+
outline-offset: 0px;
|
|
161
162
|
}
|
|
162
163
|
.header-nav .tab.active {
|
|
163
164
|
color: var(--fg);
|
|
@@ -282,7 +283,7 @@ a:focus {
|
|
|
282
283
|
outline-offset: var(--outline-offset);
|
|
283
284
|
}
|
|
284
285
|
.editable:focus-within {
|
|
285
|
-
outline: 2px solid
|
|
286
|
+
outline: 2px solid color-mix(in srgb, var(--link) 60%, transparent);
|
|
286
287
|
outline-offset: var(--outline-offset);
|
|
287
288
|
cursor: text;
|
|
288
289
|
}
|
|
@@ -385,6 +386,19 @@ button {
|
|
|
385
386
|
border-color 140ms ease,
|
|
386
387
|
transform 60ms ease;
|
|
387
388
|
}
|
|
389
|
+
/* Inline issue ID renderer: looks like plain text */
|
|
390
|
+
button.id-copy {
|
|
391
|
+
background: transparent;
|
|
392
|
+
color: inherit;
|
|
393
|
+
border: none;
|
|
394
|
+
padding: 0;
|
|
395
|
+
margin: 0;
|
|
396
|
+
line-height: inherit;
|
|
397
|
+
font: inherit;
|
|
398
|
+
}
|
|
399
|
+
button.id-copy:hover {
|
|
400
|
+
text-decoration: underline;
|
|
401
|
+
}
|
|
388
402
|
button:hover {
|
|
389
403
|
filter: brightness(1.02);
|
|
390
404
|
}
|
|
@@ -687,7 +701,7 @@ input.inline-edit {
|
|
|
687
701
|
outline-offset: var(--outline-offset);
|
|
688
702
|
}
|
|
689
703
|
input.inline-edit:focus {
|
|
690
|
-
outline: 2px solid
|
|
704
|
+
outline: 2px solid color-mix(in srgb, var(--link) 50%, transparent);
|
|
691
705
|
outline-offset: var(--outline-offset);
|
|
692
706
|
box-shadow: none;
|
|
693
707
|
}
|
|
@@ -739,7 +753,7 @@ input.inline-edit:focus {
|
|
|
739
753
|
/* Keyboard focus ring helper */
|
|
740
754
|
:focus-visible {
|
|
741
755
|
outline: 2px solid color-mix(in srgb, var(--link) 60%, transparent);
|
|
742
|
-
outline-offset:
|
|
756
|
+
outline-offset: 2px;
|
|
743
757
|
}
|
|
744
758
|
|
|
745
759
|
/* Subtle scrollbars (WebKit/Blink) */
|
|
@@ -779,6 +793,7 @@ input.inline-edit:focus {
|
|
|
779
793
|
|
|
780
794
|
/* Spacing for acceptance and notes sections in details */
|
|
781
795
|
#detail-root .acceptance,
|
|
796
|
+
#detail-root .design,
|
|
782
797
|
#detail-root .notes {
|
|
783
798
|
margin-top: 32px;
|
|
784
799
|
}
|
|
@@ -791,10 +806,17 @@ input.inline-edit:focus {
|
|
|
791
806
|
line-height: inherit;
|
|
792
807
|
}
|
|
793
808
|
/* Title input fills available width and stays inline with buttons */
|
|
794
|
-
#detail-root .detail-title
|
|
795
|
-
|
|
809
|
+
#detail-root .detail-title h2 {
|
|
810
|
+
display: flex;
|
|
811
|
+
align-items: center;
|
|
812
|
+
}
|
|
813
|
+
#detail-root .detail-title h2 input[type='text'] {
|
|
814
|
+
flex: 1;
|
|
796
815
|
padding: 0;
|
|
797
816
|
min-width: 10px;
|
|
817
|
+
font-size: inherit;
|
|
818
|
+
line-height: inherit;
|
|
819
|
+
margin-right: 8px;
|
|
798
820
|
}
|
|
799
821
|
/* Remove borders in detail view; rely on global :focus-visible outline */
|
|
800
822
|
#detail-root input[type='text'],
|
|
@@ -885,6 +907,15 @@ input.inline-edit:focus {
|
|
|
885
907
|
gap: 16px;
|
|
886
908
|
padding: 12px;
|
|
887
909
|
}
|
|
910
|
+
|
|
911
|
+
/* UI-121: stack two board columns vertically in a single grid cell */
|
|
912
|
+
.board-stack-2 {
|
|
913
|
+
display: grid;
|
|
914
|
+
grid-template-rows: 1fr 1fr;
|
|
915
|
+
gap: 16px;
|
|
916
|
+
min-width: 380px;
|
|
917
|
+
min-height: 0;
|
|
918
|
+
}
|
|
888
919
|
.board-column {
|
|
889
920
|
display: flex;
|
|
890
921
|
flex-direction: column;
|
|
@@ -896,6 +927,10 @@ input.inline-edit:focus {
|
|
|
896
927
|
background: color-mix(in srgb, var(--panel-bg) 92%, transparent);
|
|
897
928
|
}
|
|
898
929
|
.board-column__header {
|
|
930
|
+
display: flex;
|
|
931
|
+
align-items: center;
|
|
932
|
+
justify-content: space-between;
|
|
933
|
+
gap: 8px;
|
|
899
934
|
position: sticky;
|
|
900
935
|
top: 0;
|
|
901
936
|
z-index: 1;
|
|
@@ -905,6 +940,16 @@ input.inline-edit:focus {
|
|
|
905
940
|
background: inherit;
|
|
906
941
|
backdrop-filter: saturate(140%) blur(4px);
|
|
907
942
|
}
|
|
943
|
+
/* Small, unobtrusive select for closed filter */
|
|
944
|
+
.board-closed-filter {
|
|
945
|
+
margin: -8px 0;
|
|
946
|
+
}
|
|
947
|
+
.board-closed-filter select {
|
|
948
|
+
font-size: 13px;
|
|
949
|
+
border-radius: 999px;
|
|
950
|
+
border-color: var(--border);
|
|
951
|
+
padding: 2px 18px 2px 12px;
|
|
952
|
+
}
|
|
908
953
|
.board-column__body {
|
|
909
954
|
padding: 10px;
|
|
910
955
|
overflow: visible;
|
|
@@ -936,6 +981,10 @@ input.inline-edit:focus {
|
|
|
936
981
|
0 2px 8px color-mix(in srgb, #000 16%, transparent),
|
|
937
982
|
0 4px 16px color-mix(in srgb, #000 12%, transparent);
|
|
938
983
|
}
|
|
984
|
+
.board-card:focus {
|
|
985
|
+
outline: 2px solid color-mix(in srgb, var(--link) 50%, transparent);
|
|
986
|
+
outline-offset: 0px;
|
|
987
|
+
}
|
|
939
988
|
.board-card__title {
|
|
940
989
|
font-weight: 600;
|
|
941
990
|
margin-bottom: 4px;
|
|
@@ -955,6 +1004,16 @@ input.inline-edit:focus {
|
|
|
955
1004
|
}
|
|
956
1005
|
}
|
|
957
1006
|
|
|
1007
|
+
/* a11y helper */
|
|
1008
|
+
.visually-hidden {
|
|
1009
|
+
position: absolute !important;
|
|
1010
|
+
height: 1px;
|
|
1011
|
+
width: 1px;
|
|
1012
|
+
overflow: hidden;
|
|
1013
|
+
clip: rect(1px, 1px, 1px, 1px);
|
|
1014
|
+
white-space: nowrap;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
958
1017
|
@media (prefers-color-scheme: dark) {
|
|
959
1018
|
:root {
|
|
960
1019
|
--fg: #e5e7eb;
|
|
@@ -1152,7 +1211,7 @@ html[data-theme='dark'] {
|
|
|
1152
1211
|
}
|
|
1153
1212
|
.detail-side {
|
|
1154
1213
|
position: sticky;
|
|
1155
|
-
top:
|
|
1214
|
+
top: 18px;
|
|
1156
1215
|
}
|
|
1157
1216
|
.detail-main h2 {
|
|
1158
1217
|
font-size: 28px;
|
|
@@ -1220,6 +1279,14 @@ html[data-theme='dark'] {
|
|
|
1220
1279
|
padding: 0;
|
|
1221
1280
|
margin: 0 0 8px;
|
|
1222
1281
|
}
|
|
1282
|
+
.props-card ul li {
|
|
1283
|
+
display: grid;
|
|
1284
|
+
grid-template-columns: auto auto 1fr auto;
|
|
1285
|
+
gap: 6px;
|
|
1286
|
+
align-items: center;
|
|
1287
|
+
padding: 2px 0;
|
|
1288
|
+
cursor: pointer;
|
|
1289
|
+
}
|
|
1223
1290
|
.props-card__title {
|
|
1224
1291
|
font-weight: 700;
|
|
1225
1292
|
font-size: 13px;
|
|
@@ -1248,6 +1315,103 @@ html[data-theme='dark'] {
|
|
|
1248
1315
|
gap: 8px;
|
|
1249
1316
|
}
|
|
1250
1317
|
|
|
1318
|
+
/* --- Issue Details Dialog (UI-104) --- */
|
|
1319
|
+
#issue-dialog {
|
|
1320
|
+
padding: 0;
|
|
1321
|
+
border: 1px solid var(--border);
|
|
1322
|
+
border-radius: 8px;
|
|
1323
|
+
background: var(--bg);
|
|
1324
|
+
color: var(--fg);
|
|
1325
|
+
width: min(96vw, 1200px);
|
|
1326
|
+
max-height: 96vh;
|
|
1327
|
+
margin: 2vh auto;
|
|
1328
|
+
overflow: hidden;
|
|
1329
|
+
}
|
|
1330
|
+
#issue-dialog::backdrop {
|
|
1331
|
+
background: color-mix(in srgb, #000 45%, transparent);
|
|
1332
|
+
backdrop-filter: blur(1px);
|
|
1333
|
+
}
|
|
1334
|
+
.issue-dialog__header {
|
|
1335
|
+
display: flex;
|
|
1336
|
+
align-items: center;
|
|
1337
|
+
justify-content: space-between;
|
|
1338
|
+
gap: 10px;
|
|
1339
|
+
padding: 8px 12px;
|
|
1340
|
+
border-bottom: 1px solid var(--border);
|
|
1341
|
+
}
|
|
1342
|
+
.issue-dialog__title {
|
|
1343
|
+
font-weight: 700;
|
|
1344
|
+
}
|
|
1345
|
+
.issue-dialog__close {
|
|
1346
|
+
border-radius: 4px;
|
|
1347
|
+
padding: 4px 8px;
|
|
1348
|
+
min-width: 28px;
|
|
1349
|
+
line-height: 1.2;
|
|
1350
|
+
}
|
|
1351
|
+
.issue-dialog__body {
|
|
1352
|
+
min-height: 200px;
|
|
1353
|
+
max-height: calc(96vh - 44px);
|
|
1354
|
+
overflow: auto;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
.new-issue__body {
|
|
1358
|
+
padding: 12px;
|
|
1359
|
+
}
|
|
1360
|
+
.new-issue__form {
|
|
1361
|
+
display: grid;
|
|
1362
|
+
grid-template-columns: 120px 1fr;
|
|
1363
|
+
gap: 10px 12px;
|
|
1364
|
+
align-items: center;
|
|
1365
|
+
}
|
|
1366
|
+
.new-issue__form label {
|
|
1367
|
+
color: var(--muted);
|
|
1368
|
+
font-size: 12px;
|
|
1369
|
+
}
|
|
1370
|
+
.new-issue__actions {
|
|
1371
|
+
display: flex;
|
|
1372
|
+
justify-content: flex-end;
|
|
1373
|
+
gap: 8px;
|
|
1374
|
+
margin-top: 8px;
|
|
1375
|
+
}
|
|
1376
|
+
.new-issue__error {
|
|
1377
|
+
color: var(--danger, #b00020);
|
|
1378
|
+
font-size: 12px;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/* --- New Issue Dialog (UI-106) --- */
|
|
1382
|
+
#new-issue-dialog {
|
|
1383
|
+
padding: 0;
|
|
1384
|
+
border: 1px solid var(--border);
|
|
1385
|
+
border-radius: 8px;
|
|
1386
|
+
background: var(--bg);
|
|
1387
|
+
color: var(--fg);
|
|
1388
|
+
width: min(640px, 96vw);
|
|
1389
|
+
max-height: 96vh;
|
|
1390
|
+
margin: 2vh auto;
|
|
1391
|
+
overflow: hidden;
|
|
1392
|
+
}
|
|
1393
|
+
#new-issue-dialog::backdrop {
|
|
1394
|
+
background: color-mix(in srgb, #000 45%, transparent);
|
|
1395
|
+
backdrop-filter: blur(1px);
|
|
1396
|
+
}
|
|
1397
|
+
.new-issue__header {
|
|
1398
|
+
display: flex;
|
|
1399
|
+
align-items: center;
|
|
1400
|
+
justify-content: space-between;
|
|
1401
|
+
gap: 10px;
|
|
1402
|
+
padding: 8px 12px;
|
|
1403
|
+
border-bottom: 1px solid var(--border);
|
|
1404
|
+
}
|
|
1405
|
+
.new-issue__title {
|
|
1406
|
+
font-weight: 700;
|
|
1407
|
+
}
|
|
1408
|
+
.new-issue__close {
|
|
1409
|
+
border-radius: 4px;
|
|
1410
|
+
padding: 4px 8px;
|
|
1411
|
+
min-width: 28px;
|
|
1412
|
+
line-height: 1.2;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1251
1415
|
.prop.labels {
|
|
1252
1416
|
align-items: start;
|
|
1253
1417
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { issueDisplayId } from './issue-id.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a reusable, copy-to-clipboard issue ID renderer.
|
|
5
|
+
* Looks like the current inline ID (monospace `#123`) but acts as a button
|
|
6
|
+
* that copies the full, prefixed ID (e.g., `UI-123`) when activated.
|
|
7
|
+
* Shows transient "Copied" feedback and then restores the ID.
|
|
8
|
+
* @param {string} id - Full issue id including the prefix (e.g., "UI-123").
|
|
9
|
+
* @param {{ class_name?: string, duration_ms?: number }} [opts]
|
|
10
|
+
* @returns {HTMLButtonElement}
|
|
11
|
+
*/
|
|
12
|
+
export function createIssueIdRenderer(id, opts) {
|
|
13
|
+
/** @type {number} */
|
|
14
|
+
const duration =
|
|
15
|
+
typeof opts?.duration_ms === 'number' ? opts.duration_ms : 1200;
|
|
16
|
+
/** @type {HTMLButtonElement} */
|
|
17
|
+
const btn = document.createElement('button');
|
|
18
|
+
// Visual: match inline ID look; keep it neutral and text-like
|
|
19
|
+
btn.className =
|
|
20
|
+
(opts?.class_name ? opts.class_name + ' ' : '') + 'mono id-copy';
|
|
21
|
+
btn.type = 'button';
|
|
22
|
+
btn.setAttribute('aria-live', 'polite');
|
|
23
|
+
btn.setAttribute('title', 'Copy issue ID');
|
|
24
|
+
btn.setAttribute('aria-label', `Copy issue ID ${id}`);
|
|
25
|
+
const label = issueDisplayId(id);
|
|
26
|
+
btn.textContent = label;
|
|
27
|
+
|
|
28
|
+
/** Copy handler with feedback */
|
|
29
|
+
async function doCopy() {
|
|
30
|
+
// Prevent accidental row navigation and parent handlers
|
|
31
|
+
// (click/key handlers call this inside an event context)
|
|
32
|
+
try {
|
|
33
|
+
if (
|
|
34
|
+
navigator.clipboard &&
|
|
35
|
+
typeof navigator.clipboard.writeText === 'function'
|
|
36
|
+
) {
|
|
37
|
+
await navigator.clipboard.writeText(String(id));
|
|
38
|
+
}
|
|
39
|
+
const prev = btn.textContent || label;
|
|
40
|
+
btn.textContent = 'Copied';
|
|
41
|
+
// Keep accessible label consistent with feedback
|
|
42
|
+
const oldAria = btn.getAttribute('aria-label') || '';
|
|
43
|
+
btn.setAttribute('aria-label', 'Copied');
|
|
44
|
+
setTimeout(
|
|
45
|
+
() => {
|
|
46
|
+
btn.textContent = prev;
|
|
47
|
+
btn.setAttribute('aria-label', oldAria);
|
|
48
|
+
},
|
|
49
|
+
Math.max(80, duration)
|
|
50
|
+
);
|
|
51
|
+
} catch {
|
|
52
|
+
// On failure, leave text as-is; no throw to avoid disruptive UX
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
btn.addEventListener('click', (ev) => {
|
|
57
|
+
ev.preventDefault();
|
|
58
|
+
ev.stopPropagation();
|
|
59
|
+
void doCopy();
|
|
60
|
+
});
|
|
61
|
+
btn.addEventListener('keydown', (ev) => {
|
|
62
|
+
// Ensure keyboard activation works even in non-interactive test envs
|
|
63
|
+
if (ev.key === 'Enter' || ev.key === ' ') {
|
|
64
|
+
ev.preventDefault();
|
|
65
|
+
ev.stopPropagation();
|
|
66
|
+
void doCopy();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return btn;
|
|
71
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a canonical issue hash that retains the view.
|
|
3
|
+
* @param {'issues'|'epics'|'board'} view
|
|
4
|
+
* @param {string} id
|
|
5
|
+
*/
|
|
6
|
+
export function issueHashFor(view, id) {
|
|
7
|
+
const v = view === 'epics' || view === 'board' ? view : 'issues';
|
|
8
|
+
return `#/${v}?issue=${encodeURIComponent(id)}`;
|
|
9
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Show a transient global toast message anchored to the viewport.
|
|
3
|
+
* @param {string} text - Message text.
|
|
4
|
+
* @param {'info'|'success'|'error'} [variant] - Visual variant.
|
|
5
|
+
* @param {number} [duration_ms] - Auto-dismiss delay in milliseconds.
|
|
6
|
+
*/
|
|
7
|
+
export function showToast(text, variant = 'info', duration_ms = 2800) {
|
|
8
|
+
/** @type {HTMLDivElement} */
|
|
9
|
+
const el = document.createElement('div');
|
|
10
|
+
el.className = 'toast';
|
|
11
|
+
el.textContent = text;
|
|
12
|
+
el.style.position = 'fixed';
|
|
13
|
+
el.style.right = '12px';
|
|
14
|
+
el.style.bottom = '12px';
|
|
15
|
+
el.style.zIndex = '1000';
|
|
16
|
+
el.style.color = '#fff';
|
|
17
|
+
el.style.padding = '8px 10px';
|
|
18
|
+
el.style.borderRadius = '4px';
|
|
19
|
+
el.style.fontSize = '12px';
|
|
20
|
+
if (variant === 'success') {
|
|
21
|
+
el.style.background = '#156d36';
|
|
22
|
+
} else if (variant === 'error') {
|
|
23
|
+
el.style.background = '#9f2011';
|
|
24
|
+
} else {
|
|
25
|
+
el.style.background = 'rgba(0,0,0,0.85)';
|
|
26
|
+
}
|
|
27
|
+
(document.body || document.documentElement).appendChild(el);
|
|
28
|
+
setTimeout(() => {
|
|
29
|
+
try {
|
|
30
|
+
el.remove();
|
|
31
|
+
} catch {
|
|
32
|
+
/* ignore */
|
|
33
|
+
}
|
|
34
|
+
}, duration_ms);
|
|
35
|
+
}
|