@alliance-droid/status-feedback-system 3.1.0 → 4.0.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.
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
- import { Divider, Select } from '@alliance-droid/svelte-component-library';
3
- import type { FeedbackBoardState } from './state.svelte.js';
2
+ import { Divider, Select } from "@alliance-droid/svelte-component-library";
3
+ import type { FeedbackBoardState } from "./state.svelte.js";
4
4
 
5
5
  interface Props {
6
6
  board: FeedbackBoardState;
@@ -9,52 +9,75 @@
9
9
  let { board }: Props = $props();
10
10
 
11
11
  const statusOptions = [
12
- { value: 'open', label: 'Open' },
13
- { value: 'resolved', label: 'Done' },
14
- { value: 'all', label: 'All' },
12
+ { value: "open", label: "Open" },
13
+ { value: "resolved", label: "Done" },
14
+ { value: "all", label: "All" },
15
15
  ];
16
16
 
17
17
  const sortOptions = [
18
- { value: 'votes', label: 'Most Votes' },
19
- { value: 'newest', label: 'Newest' },
18
+ { value: "votes", label: "Most Votes" },
19
+ { value: "newest", label: "Newest" },
20
20
  ];
21
21
  </script>
22
22
 
23
23
  <aside class="flex flex-col gap-1" aria-label="Filter feedback">
24
- <button
25
- class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left w-full transition-colors
26
- {board.activeCategory === null ? 'bg-surface-secondary text-foreground font-semibold' : 'text-foreground-secondary hover:bg-surface-hover hover:text-foreground'}"
27
- onclick={() => (board.activeCategory = null)}
28
- >
29
- <i class="fa-solid fa-layer-group" aria-hidden="true"></i>
30
- <span>All</span>
31
- <span class="ml-auto bg-surface-hover px-2 py-0.5 rounded-full text-xs">{board.total}</span>
32
- </button>
33
-
34
- {#each board.categories as cat (cat.name)}
24
+ <div class="mx-2 pt-2">
35
25
  <button
36
26
  class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left w-full transition-colors
37
- {board.activeCategory === cat.name ? 'bg-surface-secondary text-foreground font-semibold' : 'text-foreground-secondary hover:bg-surface-hover hover:text-foreground'}"
38
- onclick={() => (board.activeCategory = board.activeCategory === cat.name ? null : cat.name)}
27
+ {board.activeCategory === null
28
+ ? 'bg-surface-secondary text-foreground font-semibold'
29
+ : 'text-foreground-secondary hover:bg-surface-hover hover:text-foreground'}"
30
+ onclick={() => (board.activeCategory = null)}
39
31
  >
40
- <i class={board.getCategoryIcon(cat.name)} aria-hidden="true"></i>
41
- <span>{board.getCategoryLabel(cat.name)}</span>
42
- <span class="ml-auto bg-surface-hover px-2 py-0.5 rounded-full text-xs">{cat.count}</span>
32
+ <i class="fa-solid fa-layer-group" aria-hidden="true"></i>
33
+ <span>All</span>
34
+ <span
35
+ class="ml-auto bg-surface-hover px-2 py-0.5 rounded-full text-xs"
36
+ >{board.total}</span
37
+ >
43
38
  </button>
44
- {/each}
39
+
40
+ {#each board.categories as cat (cat.name)}
41
+ <button
42
+ class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left w-full transition-colors
43
+ {board.activeCategory === cat.name
44
+ ? 'bg-surface-secondary text-foreground font-semibold'
45
+ : 'text-foreground-secondary hover:bg-surface-hover hover:text-foreground'}"
46
+ onclick={() =>
47
+ (board.activeCategory =
48
+ board.activeCategory === cat.name ? null : cat.name)}
49
+ >
50
+ <i class={board.getCategoryIcon(cat.name)} aria-hidden="true"
51
+ ></i>
52
+ <span>{board.getCategoryLabel(cat.name)}</span>
53
+ <span
54
+ class="ml-auto bg-surface-hover px-2 py-0.5 rounded-full text-xs"
55
+ >{cat.count}</span
56
+ >
57
+ </button>
58
+ {/each}
59
+ </div>
45
60
 
46
61
  <Divider class="my-2" />
47
62
 
48
- <div class="px-3 space-y-3">
49
- <Select
50
- label="Status"
51
- options={statusOptions}
52
- bind:value={board.statusFilter}
53
- />
54
- <Select
55
- label="Sort By"
56
- options={sortOptions}
57
- bind:value={board.sortBy}
58
- />
63
+ <div class="space-y-3">
64
+ <div class="mx-4">
65
+ <Select
66
+ label="Status"
67
+ class="rounded-lg border-0 bg-surface-hover"
68
+ options={statusOptions}
69
+ bind:value={board.statusFilter}
70
+ />
71
+ </div>
72
+ <Divider class="my-2" />
73
+ <div class="mx-4">
74
+ <Select
75
+ label="Sort By"
76
+ class="rounded-lg border-0 bg-surface-hover"
77
+ options={sortOptions}
78
+ bind:value={board.sortBy}
79
+ />
80
+ </div>
59
81
  </div>
60
82
  </aside>
83
+
@@ -1,4 +1,4 @@
1
- import type { FeedbackBoardState } from './state.svelte.js';
1
+ import type { FeedbackBoardState } from "./state.svelte.js";
2
2
  interface Props {
3
3
  board: FeedbackBoardState;
4
4
  }
@@ -1,15 +1,13 @@
1
1
  <script lang="ts">
2
- import { Card, Button, Input, Textarea, Select } from '@alliance-droid/svelte-component-library';
2
+ import { Button, Input, Textarea, Select } from '@alliance-droid/svelte-component-library';
3
+ import { FEEDBACK_CATEGORIES } from '../../types.js';
3
4
  import type { FeedbackBoardState } from './state.svelte.js';
4
- import PasteImageInput from '../shared/PasteImageInput.svelte';
5
5
 
6
6
  interface Props {
7
7
  board: FeedbackBoardState;
8
- /** Available category options for the dropdown */
9
- categories?: string[];
10
8
  }
11
9
 
12
- let { board, categories = [] }: Props = $props();
10
+ let { board }: Props = $props();
13
11
 
14
12
  let title = $state('');
15
13
  let message = $state('');
@@ -19,30 +17,96 @@
19
17
  let error = $state('');
20
18
  let pastedImage = $state<string | null>(null);
21
19
  let pastedFilename = $state('');
20
+ let fileInputEl: HTMLInputElement | undefined = $state();
21
+ let dragActive = $state(false);
22
22
  let textareaWrapperEl: HTMLDivElement | undefined = $state();
23
23
  let textareaEl: HTMLTextAreaElement | undefined = $state();
24
24
 
25
+ const MAX_BYTES = 5 * 1024 * 1024;
26
+ const ALLOWED_MIME = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
27
+
25
28
  $effect(() => {
26
29
  if (textareaWrapperEl) {
27
30
  textareaEl = textareaWrapperEl.querySelector('textarea') ?? undefined;
28
31
  }
29
32
  });
30
33
 
34
+ $effect(() => {
35
+ if (!textareaEl) return;
36
+ const handler = (e: ClipboardEvent) => handlePaste(e);
37
+ textareaEl.addEventListener('paste', handler);
38
+ return () => textareaEl?.removeEventListener('paste', handler);
39
+ });
40
+
31
41
  let isAuthenticated = $derived(!!board.userEmail);
32
42
 
33
- let categoryOptions = $derived(
34
- categories.map((c) => ({ value: c, label: board.getCategoryLabel(c) }))
35
- );
43
+ // Labels are the canonical system category names — not the consumer's
44
+ // `categoryConfig` (which only customizes the sidebar).
45
+ let categoryOptions = FEEDBACK_CATEGORIES.map((c) => ({ value: c, label: c }));
46
+
47
+ /** Validate and load an image file (shared by paste, file-pick, and drop). */
48
+ function processFile(file: File) {
49
+ if (!ALLOWED_MIME.includes(file.type)) {
50
+ error = 'Unsupported image type — PNG, JPEG, GIF, or WebP only';
51
+ return;
52
+ }
53
+ if (file.size > MAX_BYTES) {
54
+ error = 'Image too large — max 5 MB';
55
+ return;
56
+ }
57
+ const reader = new FileReader();
58
+ reader.onload = () => {
59
+ pastedImage = reader.result as string;
60
+ const ext = file.type.split('/')[1] || 'png';
61
+ const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
62
+ pastedFilename = file.name || `screenshot-${stamp}.${ext}`;
63
+ error = '';
64
+ };
65
+ reader.readAsDataURL(file);
66
+ }
67
+
68
+ function handlePaste(e: ClipboardEvent) {
69
+ const clipItems = Array.from(e.clipboardData?.items ?? []);
70
+ const imageItem = clipItems.find((i) => i.kind === 'file' && i.type.startsWith('image/'));
71
+ if (!imageItem) return;
72
+ const file = imageItem.getAsFile();
73
+ if (!file) return;
74
+ e.preventDefault();
75
+ processFile(file);
76
+ }
77
+
78
+ function handleFileSelect(e: Event) {
79
+ const input = e.currentTarget as HTMLInputElement;
80
+ const file = input.files?.[0];
81
+ if (file) processFile(file);
82
+ input.value = '';
83
+ }
84
+
85
+ function handleDrop(e: DragEvent) {
86
+ e.preventDefault();
87
+ dragActive = false;
88
+ const file = e.dataTransfer?.files?.[0];
89
+ if (file) processFile(file);
90
+ }
91
+
92
+ function formatSize(dataUrl: string): string {
93
+ const comma = dataUrl.indexOf(',');
94
+ if (comma === -1) return '';
95
+ const bytes = Math.round((dataUrl.length - comma - 1) * 0.75);
96
+ if (bytes < 1024) return `${bytes} B`;
97
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
98
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
99
+ }
36
100
 
37
101
  async function handleSubmit() {
38
- if (!message.trim() || !isAuthenticated) return;
102
+ if (!message.trim() || !category || !isAuthenticated) return;
39
103
  submitting = true;
40
104
  error = '';
41
105
  try {
42
106
  await board.submitFeedback({
43
107
  title: title.trim() || undefined,
44
108
  message: message.trim(),
45
- category: category || undefined,
109
+ category,
46
110
  pastedImage,
47
111
  });
48
112
  success = true;
@@ -60,64 +124,116 @@
60
124
  }
61
125
  </script>
62
126
 
63
- <Card variant="outlined" padding="md">
127
+ <div>
64
128
  {#if !isAuthenticated}
65
- <div class="text-center py-3 text-foreground-tertiary">
129
+ <div class="py-3 text-center text-foreground-tertiary">
66
130
  <i class="fa-solid fa-lock mr-1" aria-hidden="true"></i>
67
131
  <span class="text-sm">Sign in to submit feedback</span>
68
132
  </div>
69
133
  {:else if success}
70
- <div class="text-center py-2 text-green-400 font-semibold">
134
+ <div class="py-2 text-center font-semibold text-green-400">
71
135
  <i class="fa-solid fa-check-circle mr-1"></i> Submitted!
72
136
  </div>
73
137
  {:else}
74
- <div class="space-y-3">
75
- <div class="flex items-center gap-1.5 text-xs text-foreground-tertiary">
76
- <i class="fa-solid fa-user" aria-hidden="true"></i>
77
- <span>Submitting as <span class="font-medium text-foreground-secondary">{board.userEmail}</span></span>
78
- </div>
79
- <Input
80
- bind:value={title}
81
- placeholder="Title (optional)"
82
- />
83
- <div bind:this={textareaWrapperEl}>
84
- <Textarea
85
- value={message}
86
- oninput={(e) => (message = (e.currentTarget as HTMLTextAreaElement).value)}
87
- placeholder="Describe your feedback (paste an image to attach it)\u2026"
88
- rows={3}
138
+ <div class="space-y-2">
139
+ <!-- Composer card: title + description + attachment + toolbar -->
140
+ <div
141
+ class="overflow-hidden rounded-xl border bg-background shadow-sm transition-all {dragActive
142
+ ? 'border-primary ring-2 ring-primary/30'
143
+ : 'border-border focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20'}"
144
+ role="group"
145
+ ondragover={(e) => { e.preventDefault(); dragActive = true; }}
146
+ ondragleave={() => (dragActive = false)}
147
+ ondrop={handleDrop}
148
+ >
149
+ <div class="border-b border-border">
150
+ <Input
151
+ class="rounded-none border-0 bg-transparent px-4 py-3 text-sm font-medium hover:border-transparent focus:border-transparent"
152
+ bind:value={title}
153
+ placeholder="Title (optional)"
154
+ />
155
+ </div>
156
+
157
+ <div bind:this={textareaWrapperEl}>
158
+ <Textarea
159
+ class="resize-none rounded-none border-0 bg-transparent px-4 py-3 text-sm hover:border-transparent focus:border-transparent"
160
+ value={message}
161
+ oninput={(e) => (message = (e.currentTarget as HTMLTextAreaElement).value)}
162
+ placeholder="Describe your feedback…"
163
+ rows={4}
164
+ />
165
+ </div>
166
+
167
+ {#if pastedImage}
168
+ <div class="flex items-center gap-3 border-t border-border px-3 py-2">
169
+ <img
170
+ src={pastedImage}
171
+ alt={pastedFilename}
172
+ class="h-11 w-11 flex-shrink-0 rounded border border-border object-cover"
173
+ />
174
+ <div class="min-w-0 flex-1">
175
+ <p class="truncate text-xs font-medium text-foreground">{pastedFilename}</p>
176
+ <p class="text-[11px] text-foreground-tertiary">{formatSize(pastedImage)}</p>
177
+ </div>
178
+ <button
179
+ type="button"
180
+ class="flex-shrink-0 rounded p-1.5 text-foreground-tertiary transition-colors hover:bg-surface-hover hover:text-foreground"
181
+ onclick={() => { pastedImage = null; pastedFilename = ''; }}
182
+ aria-label="Remove image"
183
+ >
184
+ <i class="fa-solid fa-xmark" aria-hidden="true"></i>
185
+ </button>
186
+ </div>
187
+ {/if}
188
+
189
+ <!-- Toolbar -->
190
+ <div class="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-2.5">
191
+ <div class="flex items-center gap-2">
192
+ <div class="min-w-[150px]">
193
+ <Select
194
+ class="border-0 bg-transparent"
195
+ size="sm"
196
+ placeholder="Category…"
197
+ options={categoryOptions}
198
+ required
199
+ bind:value={category}
200
+ />
201
+ </div>
202
+ <button
203
+ type="button"
204
+ class="inline-flex items-center gap-1.5 rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs font-medium text-foreground-secondary transition-colors hover:border-border-strong hover:text-foreground"
205
+ onclick={() => fileInputEl?.click()}
206
+ >
207
+ <i class="fa-solid fa-image" aria-hidden="true"></i>
208
+ Attach screenshot
209
+ </button>
210
+ </div>
211
+ <Button
212
+ variant="primary"
213
+ size="sm"
214
+ disabled={submitting || !message.trim() || !category}
215
+ loading={submitting}
216
+ onclick={handleSubmit}
217
+ >
218
+ Submit
219
+ </Button>
220
+ </div>
221
+
222
+ <input
223
+ bind:this={fileInputEl}
224
+ type="file"
225
+ accept="image/png,image/jpeg,image/gif,image/webp"
226
+ class="hidden"
227
+ onchange={handleFileSelect}
89
228
  />
90
229
  </div>
91
- <PasteImageInput
92
- {textareaEl}
93
- bind:image={pastedImage}
94
- bind:filename={pastedFilename}
95
- />
230
+
96
231
  {#if error}
97
232
  <p class="text-sm text-red-400">
98
- <i class="fa-solid fa-exclamation-circle mr-1"></i>
233
+ <i class="fa-solid fa-exclamation-circle mr-1" aria-hidden="true"></i>
99
234
  {error}
100
235
  </p>
101
236
  {/if}
102
- <div class="flex gap-2 items-center flex-wrap">
103
- {#if categoryOptions.length > 0}
104
- <div class="min-w-[140px]">
105
- <Select
106
- options={[{ value: '', label: 'Category...' }, ...categoryOptions]}
107
- bind:value={category}
108
- />
109
- </div>
110
- {/if}
111
- <div class="flex-1"></div>
112
- <Button
113
- variant="primary"
114
- disabled={submitting || !message.trim()}
115
- loading={submitting}
116
- onclick={handleSubmit}
117
- >
118
- Submit
119
- </Button>
120
- </div>
121
237
  </div>
122
238
  {/if}
123
- </Card>
239
+ </div>
@@ -1,8 +1,6 @@
1
1
  import type { FeedbackBoardState } from './state.svelte.js';
2
2
  interface Props {
3
3
  board: FeedbackBoardState;
4
- /** Available category options for the dropdown */
5
- categories?: string[];
6
4
  }
7
5
  declare const FeedbackSubmitForm: import("svelte").Component<Props, {}, "">;
8
6
  type FeedbackSubmitForm = ReturnType<typeof FeedbackSubmitForm>;
@@ -0,0 +1,264 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Status badge that doubles as a status-change control for privileged users.
4
+ *
5
+ * For a viewer without a token (board.canManageStatus === false) it's just a
6
+ * read-only badge. For a privileged user the badge is a button: clicking it
7
+ * loads the transitions they may make and shows them in a menu; picking one
8
+ * applies it (prompting for a reason first when the transition requires one).
9
+ *
10
+ * The menu is teleported to <body> (the `portal` action) and positioned with
11
+ * fixed coordinates from the trigger, so it never gets clipped by an overflow
12
+ * or transformed ancestor (e.g. a sliding side rail). Same approach the
13
+ * library's Select uses.
14
+ *
15
+ * Used in both the detail panel and the list row, so a ProductOwner can change
16
+ * status without opening the item. Transitions load on demand (on first open)
17
+ * unless `loadOnMount` is set — the list stays lazy so it doesn't fire a
18
+ * request per row.
19
+ */
20
+ import { Badge, Button, Textarea, Spinner } from '@alliance-droid/svelte-component-library';
21
+ import { onMount } from 'svelte';
22
+ import type { BoardItem, BoardTransition } from '../../api.js';
23
+ import type { FeedbackBoardState } from './state.svelte.js';
24
+ import { getStatusBadgeVariant, publicStatusLabel } from './utils.js';
25
+
26
+ interface Props {
27
+ board: FeedbackBoardState;
28
+ item: BoardItem;
29
+ /** Eagerly load transitions on mount (detail view). The list leaves this off. */
30
+ loadOnMount?: boolean;
31
+ }
32
+
33
+ let { board, item, loadOnMount = false }: Props = $props();
34
+
35
+ let menuOpen = $state(false);
36
+ let pendingTarget = $state<BoardTransition | null>(null);
37
+ let reason = $state('');
38
+ let applying = $state(false);
39
+ let loading = $state(false);
40
+ /** True when the last load attempt failed (token rejected / network) vs. genuinely returned no targets. */
41
+ let loadFailed = $state(false);
42
+ let error = $state('');
43
+ let triggerEl: HTMLButtonElement | undefined = $state();
44
+ let menuEl: HTMLDivElement | undefined = $state();
45
+
46
+ // Read from the board's shared cache so detail + list reuse the same fetch.
47
+ let txns = $derived(board.transitions[item.id] ?? null);
48
+ let badgeLabel = $derived(txns?.current.display ?? publicStatusLabel(item.publicStatus));
49
+ // Changes whenever the menu's content (and thus its size) changes — drives a
50
+ // reposition so the teleported menu stays anchored after it loads/expands.
51
+ let posKey = $derived(`${menuOpen}:${loading}:${pendingTarget?.status ?? ''}:${txns?.targets.length ?? 0}`);
52
+
53
+ onMount(() => {
54
+ if (loadOnMount && board.canManageStatus) void board.loadTransitions(item.id);
55
+ });
56
+
57
+ /** Teleport the menu to <body> and keep it positioned under the trigger.
58
+ * The second arg (`posKey`) is unused directly — changing it just triggers
59
+ * the action's `update()` so the menu re-positions when its content resizes. */
60
+ function portal(node: HTMLElement, _posKey?: string) {
61
+ document.body.appendChild(node);
62
+
63
+ function position() {
64
+ if (!triggerEl) return;
65
+ const rect = triggerEl.getBoundingClientRect();
66
+ node.style.position = 'fixed';
67
+ node.style.top = `${rect.bottom + 6}px`;
68
+ node.style.zIndex = '9999';
69
+ // Left-align to the trigger, then clamp into the viewport.
70
+ let left = rect.left;
71
+ const width = node.offsetWidth;
72
+ if (left + width > window.innerWidth - 8) left = window.innerWidth - width - 8;
73
+ if (left < 8) left = 8;
74
+ node.style.left = `${left}px`;
75
+ }
76
+
77
+ position();
78
+ requestAnimationFrame(position);
79
+
80
+ const onScroll = () => position();
81
+ window.addEventListener('scroll', onScroll, true);
82
+ window.addEventListener('resize', onScroll);
83
+
84
+ return {
85
+ update() {
86
+ position();
87
+ requestAnimationFrame(position);
88
+ },
89
+ destroy() {
90
+ window.removeEventListener('scroll', onScroll, true);
91
+ window.removeEventListener('resize', onScroll);
92
+ node.remove();
93
+ },
94
+ };
95
+ }
96
+
97
+ // Close on outside click / Escape while open.
98
+ $effect(() => {
99
+ if (!menuOpen) return;
100
+ const onDocClick = (e: MouseEvent) => {
101
+ const target = e.target as Node;
102
+ if (triggerEl?.contains(target) || menuEl?.contains(target)) return;
103
+ closeMenu();
104
+ };
105
+ const onKey = (e: KeyboardEvent) => {
106
+ if (e.key === 'Escape') closeMenu();
107
+ };
108
+ document.addEventListener('click', onDocClick, true);
109
+ document.addEventListener('keydown', onKey, true);
110
+ return () => {
111
+ document.removeEventListener('click', onDocClick, true);
112
+ document.removeEventListener('keydown', onKey, true);
113
+ };
114
+ });
115
+
116
+ async function toggleMenu(e: MouseEvent) {
117
+ e.stopPropagation();
118
+ if (menuOpen) {
119
+ closeMenu();
120
+ return;
121
+ }
122
+ error = '';
123
+ pendingTarget = null;
124
+ reason = '';
125
+ // Open immediately so the spinner is visible while transitions load.
126
+ menuOpen = true;
127
+ if (!txns) await reload();
128
+ }
129
+
130
+ async function reload() {
131
+ loading = true;
132
+ loadFailed = false;
133
+ try {
134
+ // null = couldn't load (no token / rejected / network), not "no transitions".
135
+ loadFailed = (await board.loadTransitions(item.id)) === null;
136
+ } finally {
137
+ loading = false;
138
+ }
139
+ }
140
+
141
+ function closeMenu() {
142
+ menuOpen = false;
143
+ pendingTarget = null;
144
+ reason = '';
145
+ error = '';
146
+ }
147
+
148
+ async function chooseStatus(target: BoardTransition) {
149
+ error = '';
150
+ if (target.requiresReason) {
151
+ pendingTarget = target;
152
+ reason = '';
153
+ return;
154
+ }
155
+ await applyStatus(target);
156
+ }
157
+
158
+ async function applyStatus(target: BoardTransition, withReason?: string) {
159
+ applying = true;
160
+ error = '';
161
+ try {
162
+ await board.setStatus(item.id, target.status, withReason);
163
+ closeMenu();
164
+ } catch (err) {
165
+ error = err instanceof Error ? err.message : 'Failed to update status';
166
+ } finally {
167
+ applying = false;
168
+ }
169
+ }
170
+
171
+ function confirmReason() {
172
+ if (!pendingTarget) return;
173
+ if (!reason.trim()) {
174
+ error = 'A reason is required for this change';
175
+ return;
176
+ }
177
+ applyStatus(pendingTarget, reason.trim());
178
+ }
179
+ </script>
180
+
181
+ {#if board.canManageStatus}
182
+ <button
183
+ bind:this={triggerEl}
184
+ type="button"
185
+ class="inline-flex items-center gap-1.5 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-60"
186
+ onclick={toggleMenu}
187
+ aria-haspopup="listbox"
188
+ aria-expanded={menuOpen}
189
+ disabled={applying}
190
+ title="Change status"
191
+ >
192
+ <Badge variant={getStatusBadgeVariant(item.publicStatus)} size="sm">{badgeLabel}</Badge>
193
+ <i class="fa-solid fa-chevron-down text-[0.625rem] text-foreground-tertiary" aria-hidden="true"></i>
194
+ </button>
195
+
196
+ {#if menuOpen}
197
+ <!-- Teleported to <body>; posKey re-positions it when its size changes. -->
198
+ <div
199
+ bind:this={menuEl}
200
+ use:portal={posKey}
201
+ class="max-h-[60vh] w-max min-w-[10rem] overflow-y-auto overflow-x-hidden rounded-lg border border-border bg-surface py-1 shadow-lg"
202
+ role="listbox"
203
+ >
204
+ {#if loading}
205
+ <div class="flex items-center gap-2 px-3 py-3 text-sm text-foreground-tertiary">
206
+ <Spinner /> Loading…
207
+ </div>
208
+ {:else if pendingTarget}
209
+ <div class="w-64 space-y-2 p-3">
210
+ <p class="text-xs font-medium text-foreground">Reason for &ldquo;{pendingTarget.display}&rdquo;</p>
211
+ <Textarea
212
+ value={reason}
213
+ oninput={(e) => (reason = (e.currentTarget as HTMLTextAreaElement).value)}
214
+ placeholder="Why are you making this change?"
215
+ rows={2}
216
+ />
217
+ {#if error}
218
+ <p class="text-xs text-red-400">{error}</p>
219
+ {/if}
220
+ <div class="flex justify-end gap-2">
221
+ <Button variant="neutral" style="ghost" size="sm" onclick={() => { pendingTarget = null; reason = ''; error = ''; }}>
222
+ Back
223
+ </Button>
224
+ <Button variant="primary" size="sm" loading={applying} disabled={applying} onclick={confirmReason}>
225
+ Apply
226
+ </Button>
227
+ </div>
228
+ </div>
229
+ {:else if txns && txns.targets.length > 0}
230
+ {#each txns.targets as target (target.status)}
231
+ <button
232
+ type="button"
233
+ role="option"
234
+ aria-selected="false"
235
+ class="flex w-full items-center justify-between gap-3 whitespace-nowrap px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-surface-hover disabled:opacity-60"
236
+ onclick={() => chooseStatus(target)}
237
+ disabled={applying}
238
+ >
239
+ <span>{target.display}</span>
240
+ {#if target.requiresReason}
241
+ <i class="fa-solid fa-pen text-[0.625rem] text-foreground-tertiary" title="Requires a reason" aria-hidden="true"></i>
242
+ {/if}
243
+ </button>
244
+ {/each}
245
+ {:else if loadFailed}
246
+ <div class="w-56 space-y-2 p-3 text-sm">
247
+ <p class="text-foreground-secondary">
248
+ Couldn't load status options — your session may have expired.
249
+ </p>
250
+ <Button variant="neutral" style="ghost" size="sm" onclick={reload}>Try again</Button>
251
+ </div>
252
+ {:else}
253
+ <p class="whitespace-nowrap px-3 py-3 text-sm text-foreground-tertiary">No status changes available.</p>
254
+ {/if}
255
+ {#if error && !pendingTarget && !loading}
256
+ <p class="px-3 pb-2 text-xs text-red-400">{error}</p>
257
+ {/if}
258
+ </div>
259
+ {/if}
260
+ {:else}
261
+ <Badge variant={getStatusBadgeVariant(item.publicStatus)} size="sm">
262
+ {publicStatusLabel(item.publicStatus)}
263
+ </Badge>
264
+ {/if}