@alliance-droid/status-feedback-system 2.3.2 → 3.1.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/CHANGELOG.md +78 -67
- package/LICENSE +11 -11
- package/README.md +275 -273
- package/dist/api.d.ts +3 -1
- package/dist/components/DevFeedback.svelte +589 -585
- package/dist/components/DevFeedback.svelte.d.ts +7 -2
- package/dist/components/FeedbackBoard.svelte +110 -110
- package/dist/components/FeedbackButton.svelte +72 -72
- package/dist/components/FeedbackForm.svelte +155 -155
- package/dist/components/StatusBanner.svelte +105 -105
- package/dist/components/StatusPage.svelte +244 -244
- package/dist/components/board/FeedbackDetail.svelte +299 -299
- package/dist/components/board/FeedbackItem.svelte +117 -117
- package/dist/components/board/FeedbackList.svelte +58 -58
- package/dist/components/board/FeedbackSidebar.svelte +60 -60
- package/dist/components/board/FeedbackSubmitForm.svelte +123 -123
- package/dist/components/board/utils.d.ts +5 -2
- package/dist/components/board/utils.js +20 -6
- package/dist/components/dev/DevContextMenu.svelte +178 -178
- package/dist/components/dev/DevOnboardingToast.svelte +73 -73
- package/dist/components/dev/environment.d.ts +2 -6
- package/dist/components/dev/environment.js +2 -23
- package/dist/components/shared/IncidentTimeline.svelte +55 -55
- package/dist/components/shared/PasteImageInput.svelte +169 -169
- package/dist/components/shared/StatusIndicator.svelte +50 -50
- package/dist/components/shared/SystemCard.svelte +30 -30
- package/dist/components/shared/UptimeBar.svelte +85 -85
- package/dist/types.d.ts +9 -1
- package/package.json +66 -66
|
@@ -1,123 +1,123 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import { Card, Button, Input, Textarea, Select } from '@alliance-droid/svelte-component-library';
|
|
3
|
-
import type { FeedbackBoardState } from './state.svelte.js';
|
|
4
|
-
import PasteImageInput from '../shared/PasteImageInput.svelte';
|
|
5
|
-
|
|
6
|
-
interface Props {
|
|
7
|
-
board: FeedbackBoardState;
|
|
8
|
-
/** Available category options for the dropdown */
|
|
9
|
-
categories?: string[];
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
let { board, categories = [] }: Props = $props();
|
|
13
|
-
|
|
14
|
-
let title = $state('');
|
|
15
|
-
let message = $state('');
|
|
16
|
-
let category = $state('');
|
|
17
|
-
let submitting = $state(false);
|
|
18
|
-
let success = $state(false);
|
|
19
|
-
let error = $state('');
|
|
20
|
-
let pastedImage = $state<string | null>(null);
|
|
21
|
-
let pastedFilename = $state('');
|
|
22
|
-
let textareaWrapperEl: HTMLDivElement | undefined = $state();
|
|
23
|
-
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
|
24
|
-
|
|
25
|
-
$effect(() => {
|
|
26
|
-
if (textareaWrapperEl) {
|
|
27
|
-
textareaEl = textareaWrapperEl.querySelector('textarea') ?? undefined;
|
|
28
|
-
}
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
let isAuthenticated = $derived(!!board.userEmail);
|
|
32
|
-
|
|
33
|
-
let categoryOptions = $derived(
|
|
34
|
-
categories.map((c) => ({ value: c, label: board.getCategoryLabel(c) }))
|
|
35
|
-
);
|
|
36
|
-
|
|
37
|
-
async function handleSubmit() {
|
|
38
|
-
if (!message.trim() || !isAuthenticated) return;
|
|
39
|
-
submitting = true;
|
|
40
|
-
error = '';
|
|
41
|
-
try {
|
|
42
|
-
await board.submitFeedback({
|
|
43
|
-
title: title.trim() || undefined,
|
|
44
|
-
message: message.trim(),
|
|
45
|
-
category: category || undefined,
|
|
46
|
-
pastedImage,
|
|
47
|
-
});
|
|
48
|
-
success = true;
|
|
49
|
-
title = '';
|
|
50
|
-
message = '';
|
|
51
|
-
category = '';
|
|
52
|
-
pastedImage = null;
|
|
53
|
-
pastedFilename = '';
|
|
54
|
-
setTimeout(() => { success = false; }, 2000);
|
|
55
|
-
} catch (err) {
|
|
56
|
-
error = err instanceof Error ? err.message : 'Failed to submit';
|
|
57
|
-
} finally {
|
|
58
|
-
submitting = false;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
</script>
|
|
62
|
-
|
|
63
|
-
<Card variant="outlined" padding="md">
|
|
64
|
-
{#if !isAuthenticated}
|
|
65
|
-
<div class="text-center py-3 text-foreground-tertiary">
|
|
66
|
-
<i class="fa-solid fa-lock mr-1" aria-hidden="true"></i>
|
|
67
|
-
<span class="text-sm">Sign in to submit feedback</span>
|
|
68
|
-
</div>
|
|
69
|
-
{:else if success}
|
|
70
|
-
<div class="text-center py-2 text-green-400 font-semibold">
|
|
71
|
-
<i class="fa-solid fa-check-circle mr-1"></i> Submitted!
|
|
72
|
-
</div>
|
|
73
|
-
{: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}
|
|
89
|
-
/>
|
|
90
|
-
</div>
|
|
91
|
-
<PasteImageInput
|
|
92
|
-
{textareaEl}
|
|
93
|
-
bind:image={pastedImage}
|
|
94
|
-
bind:filename={pastedFilename}
|
|
95
|
-
/>
|
|
96
|
-
{#if error}
|
|
97
|
-
<p class="text-sm text-red-400">
|
|
98
|
-
<i class="fa-solid fa-exclamation-circle mr-1"></i>
|
|
99
|
-
{error}
|
|
100
|
-
</p>
|
|
101
|
-
{/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
|
-
</div>
|
|
122
|
-
{/if}
|
|
123
|
-
</Card>
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Card, Button, Input, Textarea, Select } from '@alliance-droid/svelte-component-library';
|
|
3
|
+
import type { FeedbackBoardState } from './state.svelte.js';
|
|
4
|
+
import PasteImageInput from '../shared/PasteImageInput.svelte';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
board: FeedbackBoardState;
|
|
8
|
+
/** Available category options for the dropdown */
|
|
9
|
+
categories?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let { board, categories = [] }: Props = $props();
|
|
13
|
+
|
|
14
|
+
let title = $state('');
|
|
15
|
+
let message = $state('');
|
|
16
|
+
let category = $state('');
|
|
17
|
+
let submitting = $state(false);
|
|
18
|
+
let success = $state(false);
|
|
19
|
+
let error = $state('');
|
|
20
|
+
let pastedImage = $state<string | null>(null);
|
|
21
|
+
let pastedFilename = $state('');
|
|
22
|
+
let textareaWrapperEl: HTMLDivElement | undefined = $state();
|
|
23
|
+
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
|
24
|
+
|
|
25
|
+
$effect(() => {
|
|
26
|
+
if (textareaWrapperEl) {
|
|
27
|
+
textareaEl = textareaWrapperEl.querySelector('textarea') ?? undefined;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let isAuthenticated = $derived(!!board.userEmail);
|
|
32
|
+
|
|
33
|
+
let categoryOptions = $derived(
|
|
34
|
+
categories.map((c) => ({ value: c, label: board.getCategoryLabel(c) }))
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
async function handleSubmit() {
|
|
38
|
+
if (!message.trim() || !isAuthenticated) return;
|
|
39
|
+
submitting = true;
|
|
40
|
+
error = '';
|
|
41
|
+
try {
|
|
42
|
+
await board.submitFeedback({
|
|
43
|
+
title: title.trim() || undefined,
|
|
44
|
+
message: message.trim(),
|
|
45
|
+
category: category || undefined,
|
|
46
|
+
pastedImage,
|
|
47
|
+
});
|
|
48
|
+
success = true;
|
|
49
|
+
title = '';
|
|
50
|
+
message = '';
|
|
51
|
+
category = '';
|
|
52
|
+
pastedImage = null;
|
|
53
|
+
pastedFilename = '';
|
|
54
|
+
setTimeout(() => { success = false; }, 2000);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
error = err instanceof Error ? err.message : 'Failed to submit';
|
|
57
|
+
} finally {
|
|
58
|
+
submitting = false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<Card variant="outlined" padding="md">
|
|
64
|
+
{#if !isAuthenticated}
|
|
65
|
+
<div class="text-center py-3 text-foreground-tertiary">
|
|
66
|
+
<i class="fa-solid fa-lock mr-1" aria-hidden="true"></i>
|
|
67
|
+
<span class="text-sm">Sign in to submit feedback</span>
|
|
68
|
+
</div>
|
|
69
|
+
{:else if success}
|
|
70
|
+
<div class="text-center py-2 text-green-400 font-semibold">
|
|
71
|
+
<i class="fa-solid fa-check-circle mr-1"></i> Submitted!
|
|
72
|
+
</div>
|
|
73
|
+
{: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}
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
<PasteImageInput
|
|
92
|
+
{textareaEl}
|
|
93
|
+
bind:image={pastedImage}
|
|
94
|
+
bind:filename={pastedFilename}
|
|
95
|
+
/>
|
|
96
|
+
{#if error}
|
|
97
|
+
<p class="text-sm text-red-400">
|
|
98
|
+
<i class="fa-solid fa-exclamation-circle mr-1"></i>
|
|
99
|
+
{error}
|
|
100
|
+
</p>
|
|
101
|
+
{/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
|
+
</div>
|
|
122
|
+
{/if}
|
|
123
|
+
</Card>
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
/** Relative time formatting for feedback timestamps */
|
|
2
2
|
export declare function timeAgo(timestamp: number): string;
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import type { PublicStatus } from '../../types.js';
|
|
4
|
+
/** Human label for a public board status. */
|
|
5
|
+
export declare function publicStatusLabel(status: PublicStatus): string;
|
|
6
|
+
/** Map a public board status to a badge variant. */
|
|
7
|
+
export declare function getStatusBadgeVariant(status: PublicStatus): 'success' | 'neutral' | 'info' | 'warning';
|
|
@@ -12,11 +12,25 @@ export function timeAgo(timestamp) {
|
|
|
12
12
|
return `${days}d ago`;
|
|
13
13
|
return new Date(timestamp).toLocaleDateString();
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
const PUBLIC_STATUS_LABELS = {
|
|
16
|
+
open: 'Open',
|
|
17
|
+
planned: 'Planned',
|
|
18
|
+
in_progress: 'In progress',
|
|
19
|
+
done: 'Done',
|
|
20
|
+
declined: 'Declined'
|
|
21
|
+
};
|
|
22
|
+
const PUBLIC_STATUS_VARIANTS = {
|
|
23
|
+
open: 'info',
|
|
24
|
+
planned: 'info',
|
|
25
|
+
in_progress: 'warning',
|
|
26
|
+
done: 'success',
|
|
27
|
+
declined: 'neutral'
|
|
28
|
+
};
|
|
29
|
+
/** Human label for a public board status. */
|
|
30
|
+
export function publicStatusLabel(status) {
|
|
31
|
+
return PUBLIC_STATUS_LABELS[status] ?? status;
|
|
32
|
+
}
|
|
33
|
+
/** Map a public board status to a badge variant. */
|
|
16
34
|
export function getStatusBadgeVariant(status) {
|
|
17
|
-
|
|
18
|
-
return 'success';
|
|
19
|
-
if (status === 'resolved')
|
|
20
|
-
return 'neutral';
|
|
21
|
-
return 'info';
|
|
35
|
+
return PUBLIC_STATUS_VARIANTS[status] ?? 'neutral';
|
|
22
36
|
}
|
|
@@ -1,178 +1,178 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
interface Props {
|
|
3
|
-
x: number;
|
|
4
|
-
y: number;
|
|
5
|
-
boardUrl?: string;
|
|
6
|
-
onReportBug: () => void;
|
|
7
|
-
onRequestFeature: () => void;
|
|
8
|
-
onViewBoard?: () => void;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
import { onMount, onDestroy } from 'svelte';
|
|
12
|
-
|
|
13
|
-
let { x, y, boardUrl, onReportBug, onRequestFeature, onViewBoard }: Props = $props();
|
|
14
|
-
|
|
15
|
-
let popoverEl: HTMLDivElement | undefined = $state();
|
|
16
|
-
let originalParent: HTMLElement | null = null;
|
|
17
|
-
let portalTarget: HTMLElement | null = null;
|
|
18
|
-
|
|
19
|
-
let adjustedX = $derived(Math.min(x, (typeof window !== 'undefined' ? window.innerWidth : 800) - 200));
|
|
20
|
-
let adjustedY = $derived(Math.min(y, (typeof window !== 'undefined' ? window.innerHeight : 600) - 160));
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Find the topmost open modal dialog.
|
|
24
|
-
* showModal() makes everything outside the dialog inert — including
|
|
25
|
-
* top-layer popovers that aren't descendants of the dialog.
|
|
26
|
-
* By portalling into the dialog, our menu becomes a descendant and
|
|
27
|
-
* escapes the inertness.
|
|
28
|
-
*/
|
|
29
|
-
function findOpenModalDialog(): HTMLDialogElement | null {
|
|
30
|
-
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog[open]');
|
|
31
|
-
// showModal() sets the `modal` property — filter for true modals
|
|
32
|
-
for (const d of dialogs) {
|
|
33
|
-
// A dialog opened with showModal() will have a non-null ::backdrop
|
|
34
|
-
// and will be in the top layer. We can detect this via the "open"
|
|
35
|
-
// attribute + checking if it has modal behavior (no direct API,
|
|
36
|
-
// but modal dialogs block interaction with the rest of the page).
|
|
37
|
-
// The simplest reliable check: modal dialogs have returnValue capability
|
|
38
|
-
// and are in the top layer. In practice, all open dialogs we care about
|
|
39
|
-
// are modal since our Modal component always uses showModal().
|
|
40
|
-
if (d.open) return d;
|
|
41
|
-
}
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
onMount(() => {
|
|
46
|
-
if (!popoverEl) return;
|
|
47
|
-
|
|
48
|
-
// If a modal dialog is open, portal into it to escape inertness
|
|
49
|
-
const modalDialog = findOpenModalDialog();
|
|
50
|
-
if (modalDialog) {
|
|
51
|
-
originalParent = popoverEl.parentElement;
|
|
52
|
-
portalTarget = modalDialog;
|
|
53
|
-
modalDialog.appendChild(popoverEl);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Show the popover to promote it to the top layer
|
|
57
|
-
if (typeof popoverEl.showPopover === 'function') {
|
|
58
|
-
try {
|
|
59
|
-
popoverEl.showPopover();
|
|
60
|
-
} catch {
|
|
61
|
-
// Fallback: popover API not supported, z-index inline style still applies
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
onDestroy(() => {
|
|
67
|
-
// Move the element back before Svelte tries to clean up
|
|
68
|
-
if (popoverEl && portalTarget && originalParent) {
|
|
69
|
-
try {
|
|
70
|
-
if (typeof popoverEl.hidePopover === 'function') {
|
|
71
|
-
popoverEl.hidePopover();
|
|
72
|
-
}
|
|
73
|
-
originalParent.appendChild(popoverEl);
|
|
74
|
-
} catch {
|
|
75
|
-
// Element may already be removed
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
function handleKeydown(e: KeyboardEvent) {
|
|
81
|
-
const menu = (e.currentTarget as HTMLElement);
|
|
82
|
-
const items = menu.querySelectorAll<HTMLElement>('[role="menuitem"]');
|
|
83
|
-
const currentIndex = Array.from(items).indexOf(e.target as HTMLElement);
|
|
84
|
-
|
|
85
|
-
if (e.key === 'ArrowDown') {
|
|
86
|
-
e.preventDefault();
|
|
87
|
-
const next = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
|
88
|
-
items[next]?.focus();
|
|
89
|
-
} else if (e.key === 'ArrowUp') {
|
|
90
|
-
e.preventDefault();
|
|
91
|
-
const prev = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
|
92
|
-
items[prev]?.focus();
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
</script>
|
|
96
|
-
|
|
97
|
-
<!-- popover="manual" promotes to browser top layer (above <dialog> modals) -->
|
|
98
|
-
<div
|
|
99
|
-
bind:this={popoverEl}
|
|
100
|
-
role="presentation"
|
|
101
|
-
popover="manual"
|
|
102
|
-
class="sf-ctx-popover"
|
|
103
|
-
style="left: {adjustedX}px; top: {adjustedY}px;"
|
|
104
|
-
onmousedown={(e) => e.stopPropagation()}
|
|
105
|
-
onclick={(e) => e.stopPropagation()}
|
|
106
|
-
onkeydown={(e) => e.stopPropagation()}
|
|
107
|
-
>
|
|
108
|
-
<div class="sf-ctx-menu" role="menu" tabindex="-1" aria-label="Feedback actions" onkeydown={handleKeydown}>
|
|
109
|
-
<button class="sf-ctx-item" role="menuitem" onclick={onReportBug}>
|
|
110
|
-
<i class="fa-solid fa-bug text-red-400" aria-hidden="true"></i>
|
|
111
|
-
Report Bug
|
|
112
|
-
</button>
|
|
113
|
-
<button class="sf-ctx-item" role="menuitem" onclick={onRequestFeature}>
|
|
114
|
-
<i class="fa-solid fa-lightbulb text-amber-400" aria-hidden="true"></i>
|
|
115
|
-
Request Feature
|
|
116
|
-
</button>
|
|
117
|
-
{#if boardUrl && onViewBoard}
|
|
118
|
-
<div class="sf-ctx-divider" role="separator">
|
|
119
|
-
<button class="sf-ctx-item sf-ctx-item-secondary" role="menuitem" onclick={onViewBoard}>
|
|
120
|
-
<i class="fa-solid fa-clipboard-list" aria-hidden="true"></i>
|
|
121
|
-
View Open Issues
|
|
122
|
-
</button>
|
|
123
|
-
</div>
|
|
124
|
-
{/if}
|
|
125
|
-
</div>
|
|
126
|
-
</div>
|
|
127
|
-
|
|
128
|
-
<style>
|
|
129
|
-
/* Override popover defaults — remove browser default styles */
|
|
130
|
-
.sf-ctx-popover {
|
|
131
|
-
position: fixed;
|
|
132
|
-
margin: 0;
|
|
133
|
-
padding: 0;
|
|
134
|
-
border: none;
|
|
135
|
-
background: transparent;
|
|
136
|
-
overflow: visible;
|
|
137
|
-
/* Fallback for browsers without popover support */
|
|
138
|
-
z-index: 99999;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
.sf-ctx-menu {
|
|
142
|
-
background: var(--sf-surface, var(--color-surface, #1e293b));
|
|
143
|
-
border: 1px solid var(--sf-border, var(--color-border, #334155));
|
|
144
|
-
border-radius: 0.5rem;
|
|
145
|
-
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
|
|
146
|
-
overflow: hidden;
|
|
147
|
-
min-width: 180px;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
.sf-ctx-item {
|
|
151
|
-
display: flex;
|
|
152
|
-
align-items: center;
|
|
153
|
-
gap: 0.625rem;
|
|
154
|
-
width: 100%;
|
|
155
|
-
padding: 0.625rem 0.75rem;
|
|
156
|
-
font-size: 0.875rem;
|
|
157
|
-
color: var(--sf-foreground, var(--color-foreground, #e2e8f0));
|
|
158
|
-
background: transparent;
|
|
159
|
-
border: none;
|
|
160
|
-
cursor: pointer;
|
|
161
|
-
text-align: left;
|
|
162
|
-
transition: background 0.15s;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
.sf-ctx-item:hover,
|
|
166
|
-
.sf-ctx-item:focus-visible {
|
|
167
|
-
background: var(--sf-surface-hover, var(--color-surface-hover, #334155));
|
|
168
|
-
outline: none;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
.sf-ctx-item-secondary {
|
|
172
|
-
color: var(--sf-foreground-secondary, var(--color-foreground-secondary, #94a3b8));
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
.sf-ctx-divider {
|
|
176
|
-
border-top: 1px solid var(--sf-border, var(--color-border, #334155));
|
|
177
|
-
}
|
|
178
|
-
</style>
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
boardUrl?: string;
|
|
6
|
+
onReportBug: () => void;
|
|
7
|
+
onRequestFeature: () => void;
|
|
8
|
+
onViewBoard?: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
import { onMount, onDestroy } from 'svelte';
|
|
12
|
+
|
|
13
|
+
let { x, y, boardUrl, onReportBug, onRequestFeature, onViewBoard }: Props = $props();
|
|
14
|
+
|
|
15
|
+
let popoverEl: HTMLDivElement | undefined = $state();
|
|
16
|
+
let originalParent: HTMLElement | null = null;
|
|
17
|
+
let portalTarget: HTMLElement | null = null;
|
|
18
|
+
|
|
19
|
+
let adjustedX = $derived(Math.min(x, (typeof window !== 'undefined' ? window.innerWidth : 800) - 200));
|
|
20
|
+
let adjustedY = $derived(Math.min(y, (typeof window !== 'undefined' ? window.innerHeight : 600) - 160));
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Find the topmost open modal dialog.
|
|
24
|
+
* showModal() makes everything outside the dialog inert — including
|
|
25
|
+
* top-layer popovers that aren't descendants of the dialog.
|
|
26
|
+
* By portalling into the dialog, our menu becomes a descendant and
|
|
27
|
+
* escapes the inertness.
|
|
28
|
+
*/
|
|
29
|
+
function findOpenModalDialog(): HTMLDialogElement | null {
|
|
30
|
+
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog[open]');
|
|
31
|
+
// showModal() sets the `modal` property — filter for true modals
|
|
32
|
+
for (const d of dialogs) {
|
|
33
|
+
// A dialog opened with showModal() will have a non-null ::backdrop
|
|
34
|
+
// and will be in the top layer. We can detect this via the "open"
|
|
35
|
+
// attribute + checking if it has modal behavior (no direct API,
|
|
36
|
+
// but modal dialogs block interaction with the rest of the page).
|
|
37
|
+
// The simplest reliable check: modal dialogs have returnValue capability
|
|
38
|
+
// and are in the top layer. In practice, all open dialogs we care about
|
|
39
|
+
// are modal since our Modal component always uses showModal().
|
|
40
|
+
if (d.open) return d;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onMount(() => {
|
|
46
|
+
if (!popoverEl) return;
|
|
47
|
+
|
|
48
|
+
// If a modal dialog is open, portal into it to escape inertness
|
|
49
|
+
const modalDialog = findOpenModalDialog();
|
|
50
|
+
if (modalDialog) {
|
|
51
|
+
originalParent = popoverEl.parentElement;
|
|
52
|
+
portalTarget = modalDialog;
|
|
53
|
+
modalDialog.appendChild(popoverEl);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Show the popover to promote it to the top layer
|
|
57
|
+
if (typeof popoverEl.showPopover === 'function') {
|
|
58
|
+
try {
|
|
59
|
+
popoverEl.showPopover();
|
|
60
|
+
} catch {
|
|
61
|
+
// Fallback: popover API not supported, z-index inline style still applies
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
onDestroy(() => {
|
|
67
|
+
// Move the element back before Svelte tries to clean up
|
|
68
|
+
if (popoverEl && portalTarget && originalParent) {
|
|
69
|
+
try {
|
|
70
|
+
if (typeof popoverEl.hidePopover === 'function') {
|
|
71
|
+
popoverEl.hidePopover();
|
|
72
|
+
}
|
|
73
|
+
originalParent.appendChild(popoverEl);
|
|
74
|
+
} catch {
|
|
75
|
+
// Element may already be removed
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
81
|
+
const menu = (e.currentTarget as HTMLElement);
|
|
82
|
+
const items = menu.querySelectorAll<HTMLElement>('[role="menuitem"]');
|
|
83
|
+
const currentIndex = Array.from(items).indexOf(e.target as HTMLElement);
|
|
84
|
+
|
|
85
|
+
if (e.key === 'ArrowDown') {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
const next = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
|
88
|
+
items[next]?.focus();
|
|
89
|
+
} else if (e.key === 'ArrowUp') {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
const prev = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
|
92
|
+
items[prev]?.focus();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<!-- popover="manual" promotes to browser top layer (above <dialog> modals) -->
|
|
98
|
+
<div
|
|
99
|
+
bind:this={popoverEl}
|
|
100
|
+
role="presentation"
|
|
101
|
+
popover="manual"
|
|
102
|
+
class="sf-ctx-popover"
|
|
103
|
+
style="left: {adjustedX}px; top: {adjustedY}px;"
|
|
104
|
+
onmousedown={(e) => e.stopPropagation()}
|
|
105
|
+
onclick={(e) => e.stopPropagation()}
|
|
106
|
+
onkeydown={(e) => e.stopPropagation()}
|
|
107
|
+
>
|
|
108
|
+
<div class="sf-ctx-menu" role="menu" tabindex="-1" aria-label="Feedback actions" onkeydown={handleKeydown}>
|
|
109
|
+
<button class="sf-ctx-item" role="menuitem" onclick={onReportBug}>
|
|
110
|
+
<i class="fa-solid fa-bug text-red-400" aria-hidden="true"></i>
|
|
111
|
+
Report Bug
|
|
112
|
+
</button>
|
|
113
|
+
<button class="sf-ctx-item" role="menuitem" onclick={onRequestFeature}>
|
|
114
|
+
<i class="fa-solid fa-lightbulb text-amber-400" aria-hidden="true"></i>
|
|
115
|
+
Request Feature
|
|
116
|
+
</button>
|
|
117
|
+
{#if boardUrl && onViewBoard}
|
|
118
|
+
<div class="sf-ctx-divider" role="separator">
|
|
119
|
+
<button class="sf-ctx-item sf-ctx-item-secondary" role="menuitem" onclick={onViewBoard}>
|
|
120
|
+
<i class="fa-solid fa-clipboard-list" aria-hidden="true"></i>
|
|
121
|
+
View Open Issues
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
{/if}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<style>
|
|
129
|
+
/* Override popover defaults — remove browser default styles */
|
|
130
|
+
.sf-ctx-popover {
|
|
131
|
+
position: fixed;
|
|
132
|
+
margin: 0;
|
|
133
|
+
padding: 0;
|
|
134
|
+
border: none;
|
|
135
|
+
background: transparent;
|
|
136
|
+
overflow: visible;
|
|
137
|
+
/* Fallback for browsers without popover support */
|
|
138
|
+
z-index: 99999;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.sf-ctx-menu {
|
|
142
|
+
background: var(--sf-surface, var(--color-surface, #1e293b));
|
|
143
|
+
border: 1px solid var(--sf-border, var(--color-border, #334155));
|
|
144
|
+
border-radius: 0.5rem;
|
|
145
|
+
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
|
|
146
|
+
overflow: hidden;
|
|
147
|
+
min-width: 180px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.sf-ctx-item {
|
|
151
|
+
display: flex;
|
|
152
|
+
align-items: center;
|
|
153
|
+
gap: 0.625rem;
|
|
154
|
+
width: 100%;
|
|
155
|
+
padding: 0.625rem 0.75rem;
|
|
156
|
+
font-size: 0.875rem;
|
|
157
|
+
color: var(--sf-foreground, var(--color-foreground, #e2e8f0));
|
|
158
|
+
background: transparent;
|
|
159
|
+
border: none;
|
|
160
|
+
cursor: pointer;
|
|
161
|
+
text-align: left;
|
|
162
|
+
transition: background 0.15s;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.sf-ctx-item:hover,
|
|
166
|
+
.sf-ctx-item:focus-visible {
|
|
167
|
+
background: var(--sf-surface-hover, var(--color-surface-hover, #334155));
|
|
168
|
+
outline: none;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.sf-ctx-item-secondary {
|
|
172
|
+
color: var(--sf-foreground-secondary, var(--color-foreground-secondary, #94a3b8));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.sf-ctx-divider {
|
|
176
|
+
border-top: 1px solid var(--sf-border, var(--color-border, #334155));
|
|
177
|
+
}
|
|
178
|
+
</style>
|