-
+
diff --git a/resources/js/components/ui/dialog/DialogContent.vue b/resources/js/components/ui/dialog/DialogContent.vue
index 7f86b47..c51579f 100644
--- a/resources/js/components/ui/dialog/DialogContent.vue
+++ b/resources/js/components/ui/dialog/DialogContent.vue
@@ -1,53 +1,67 @@
-
-
-
-
+
+
+
+
-
-
- Close
-
-
-
+
+
+ {{ t("common.close") }}
+
+
+
diff --git a/resources/js/components/ui/dialog/DialogHeader.vue b/resources/js/components/ui/dialog/DialogHeader.vue
index bfc3c64..8459b4c 100644
--- a/resources/js/components/ui/dialog/DialogHeader.vue
+++ b/resources/js/components/ui/dialog/DialogHeader.vue
@@ -1,17 +1,17 @@
-
-
-
+
+
+
diff --git a/resources/js/components/ui/dialog/DialogScrollContent.vue b/resources/js/components/ui/dialog/DialogScrollContent.vue
index f2475db..5e3c345 100644
--- a/resources/js/components/ui/dialog/DialogScrollContent.vue
+++ b/resources/js/components/ui/dialog/DialogScrollContent.vue
@@ -1,59 +1,71 @@
-
-
- {
- const originalEvent = event.detail.originalEvent;
- const target = originalEvent.target as HTMLElement;
- if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
- event.preventDefault();
- }
- }"
- >
-
-
-
+
-
- Close
-
-
-
-
+ {
+ const originalEvent = event.detail.originalEvent;
+ const target = originalEvent.target as HTMLElement;
+ if (
+ originalEvent.offsetX > target.clientWidth ||
+ originalEvent.offsetY > target.clientHeight
+ ) {
+ event.preventDefault();
+ }
+ }
+ "
+ >
+
+
+
+
+ {{ t("common.close") }}
+
+
+
+
diff --git a/resources/js/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue b/resources/js/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
index 1253078..6a1d88f 100644
--- a/resources/js/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
+++ b/resources/js/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
@@ -1,39 +1,48 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/ui/dropdown-menu/DropdownMenuContent.vue b/resources/js/components/ui/dropdown-menu/DropdownMenuContent.vue
index 7c43014..8e4b0c6 100644
--- a/resources/js/components/ui/dropdown-menu/DropdownMenuContent.vue
+++ b/resources/js/components/ui/dropdown-menu/DropdownMenuContent.vue
@@ -1,39 +1,49 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/resources/js/components/ui/dropdown-menu/DropdownMenuItem.vue b/resources/js/components/ui/dropdown-menu/DropdownMenuItem.vue
index f56cae3..aec4fc5 100644
--- a/resources/js/components/ui/dropdown-menu/DropdownMenuItem.vue
+++ b/resources/js/components/ui/dropdown-menu/DropdownMenuItem.vue
@@ -1,31 +1,41 @@
-
-
-
+
+
+
diff --git a/resources/js/components/ui/dropdown-menu/DropdownMenuRadioItem.vue b/resources/js/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
index e03c40c..f94eebe 100644
--- a/resources/js/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
+++ b/resources/js/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
@@ -1,40 +1,49 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/ui/dropdown-menu/DropdownMenuSubContent.vue b/resources/js/components/ui/dropdown-menu/DropdownMenuSubContent.vue
index d7c6b08..b8573a1 100644
--- a/resources/js/components/ui/dropdown-menu/DropdownMenuSubContent.vue
+++ b/resources/js/components/ui/dropdown-menu/DropdownMenuSubContent.vue
@@ -1,27 +1,34 @@
-
-
-
+
+
+
diff --git a/resources/js/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue b/resources/js/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
index 1683aaf..fa451c8 100644
--- a/resources/js/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
+++ b/resources/js/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
@@ -1,30 +1,34 @@
-
-
-
-
+
+
+
+
diff --git a/resources/js/components/ui/sheet/SheetContent.vue b/resources/js/components/ui/sheet/SheetContent.vue
index e0c4b8f..f3a636f 100644
--- a/resources/js/components/ui/sheet/SheetContent.vue
+++ b/resources/js/components/ui/sheet/SheetContent.vue
@@ -1,62 +1,70 @@
-
-
-
-
+
+
+
+
-
-
- Close
-
-
-
+
+
+ {{ t("common.close") }}
+
+
+
diff --git a/resources/js/composables/useDialogFocus.ts b/resources/js/composables/useDialogFocus.ts
new file mode 100644
index 0000000..c05a044
--- /dev/null
+++ b/resources/js/composables/useDialogFocus.ts
@@ -0,0 +1,34 @@
+import { injectDialogRootContext } from 'reka-ui';
+import { watch } from 'vue';
+
+/** Reka restores registered triggers; also support dialogs opened programmatically. */
+export function useDialogFocus() {
+ const context = injectDialogRootContext();
+ let previousFocus: HTMLElement | null = null;
+
+ watch(
+ context.open,
+ (open) => {
+ if (open && typeof document !== 'undefined') {
+ previousFocus =
+ document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null;
+ }
+ },
+ { flush: 'sync', immediate: true },
+ );
+
+ function restoreFocus(event: Event): void {
+ if (
+ !event.defaultPrevented &&
+ !context.triggerElement.value &&
+ previousFocus?.isConnected
+ ) {
+ event.preventDefault();
+ previousFocus.focus({ preventScroll: true });
+ }
+ }
+
+ return { restoreFocus };
+}
diff --git a/resources/js/composables/useFormProtection.ts b/resources/js/composables/useFormProtection.ts
new file mode 100644
index 0000000..83b6c9f
--- /dev/null
+++ b/resources/js/composables/useFormProtection.ts
@@ -0,0 +1,114 @@
+import type {
+ PendingVisit,
+ UrlMethodPair,
+ UseFormSubmitOptions,
+} from '@inertiajs/core';
+import { router } from '@inertiajs/vue3';
+import type { InertiaForm } from '@inertiajs/vue3';
+import { computed, onMounted, onUnmounted, ref } from 'vue';
+import { useUnsavedChanges } from '@/composables/useUnsavedChanges';
+import {
+ formRequestHeader,
+ isFormNetworkFailure,
+ matchesFormRequest,
+} from '@/lib/form-feedback';
+import type { FormFeedbackState } from '@/lib/form-feedback';
+
+/** Pair with a keyed useForm/useRemember for restoration on browser Back/Forward. */
+export function useFormProtection
(
+ form: InertiaForm,
+ options: { isDirty?: () => boolean } = {},
+) {
+ const failure = ref(null);
+ let ownVisit: PendingVisit | null = null;
+ let requestId: string | null = null;
+ const removeListeners: Array<() => void> = [];
+
+ const { confirmLeave } = useUnsavedChanges(
+ () => form.processing || (options.isDirty?.() ?? form.isDirty),
+ (visit) => visit === ownVisit,
+ );
+
+ const state = computed(() => {
+ if (form.processing) {
+ return 'loading';
+ }
+ if (failure.value) {
+ return failure.value;
+ }
+ if (Object.keys(form.errors).length > 0) {
+ return 'validation';
+ }
+ return form.recentlySuccessful ? 'success' : 'idle';
+ });
+
+ onMounted(() => {
+ removeListeners.push(
+ router.on('exception', (event) => {
+ if (isFormNetworkFailure(event.detail.exception, requestId)) {
+ event.preventDefault();
+ failure.value = 'network';
+ }
+ }),
+ router.on('invalid', (event) => {
+ if (
+ matchesFormRequest(event.detail.response.config, requestId)
+ ) {
+ event.preventDefault();
+ failure.value = 'server';
+ }
+ }),
+ );
+ });
+
+ onUnmounted(() => removeListeners.forEach((remove) => remove()));
+
+ function submit(
+ target: UrlMethodPair,
+ visitOptions: UseFormSubmitOptions = {},
+ ): void {
+ if (form.processing) {
+ return;
+ }
+
+ const nextRequestId = crypto.randomUUID();
+ form.submit(target, {
+ ...visitOptions,
+ preserveState: true,
+ headers: {
+ ...visitOptions.headers,
+ [formRequestHeader]: nextRequestId,
+ },
+ onBefore: (visit) => {
+ if (visitOptions.onBefore?.(visit) === false) {
+ return false;
+ }
+ ownVisit = visit;
+ },
+ onStart: (visit) => {
+ requestId = nextRequestId;
+ failure.value = null;
+ visitOptions.onStart?.(visit);
+ },
+ onError: (errors) => {
+ failure.value = 'validation';
+ return visitOptions.onError?.(errors);
+ },
+ onSuccess: (page) => {
+ failure.value = null;
+ return visitOptions.onSuccess?.(page);
+ },
+ onCancel: () => {
+ failure.value = 'cancelled';
+ visitOptions.onCancel?.();
+ },
+ onFinish: (visit) => {
+ requestId = null;
+ ownVisit = null;
+ visitOptions.onFinish?.(visit);
+ },
+ });
+ }
+
+ return { state, submit, confirmLeave };
+}
diff --git a/resources/js/composables/useUnsavedChanges.ts b/resources/js/composables/useUnsavedChanges.ts
new file mode 100644
index 0000000..23bc3a4
--- /dev/null
+++ b/resources/js/composables/useUnsavedChanges.ts
@@ -0,0 +1,42 @@
+import type { PendingVisit } from '@inertiajs/core';
+import { router } from '@inertiajs/vue3';
+import { onMounted, onUnmounted, toValue } from 'vue';
+import type { MaybeRefOrGetter } from 'vue';
+import { useLocalization } from '@/composables/useLocalization';
+
+export function useUnsavedChanges(
+ dirty: MaybeRefOrGetter,
+ allowVisit: (visit: PendingVisit) => boolean = () => false,
+) {
+ const { t } = useLocalization();
+ let removeBeforeListener: (() => void) | undefined;
+
+ const confirmLeave = (): boolean =>
+ !toValue(dirty) || window.confirm(t('forms.leaveConfirm'));
+
+ const beforeUnload = (event: BeforeUnloadEvent): void => {
+ if (toValue(dirty)) {
+ event.preventDefault();
+ event.returnValue = '';
+ }
+ };
+
+ onMounted(() => {
+ removeBeforeListener = router.on('before', (event) => {
+ if (event.defaultPrevented || allowVisit(event.detail.visit)) {
+ return;
+ }
+
+ // Keep the original visit and its callbacks; never replay a mutation.
+ return confirmLeave();
+ });
+ window.addEventListener('beforeunload', beforeUnload);
+ });
+
+ onUnmounted(() => {
+ removeBeforeListener?.();
+ window.removeEventListener('beforeunload', beforeUnload);
+ });
+
+ return { confirmLeave };
+}
diff --git a/resources/js/lib/form-feedback.ts b/resources/js/lib/form-feedback.ts
new file mode 100644
index 0000000..1caa940
--- /dev/null
+++ b/resources/js/lib/form-feedback.ts
@@ -0,0 +1,42 @@
+export type FormFeedbackState =
+ | 'idle'
+ | 'loading'
+ | 'success'
+ | 'validation'
+ | 'network'
+ | 'server'
+ | 'cancelled';
+
+export const formRequestHeader = 'X-KanbanGit-Form-Request';
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null;
+}
+
+/** Match failures to one submission, without intercepting other forms or polling. */
+export function matchesFormRequest(
+ config: unknown,
+ requestId: string | null,
+): boolean {
+ if (!requestId || !isRecord(config) || !isRecord(config.headers)) {
+ return false;
+ }
+
+ return config.headers[formRequestHeader] === requestId;
+}
+
+/** Runtime exceptions and cancellations are not network failures. */
+export function isFormNetworkFailure(
+ error: unknown,
+ requestId: string | null,
+): boolean {
+ return (
+ isRecord(error) &&
+ error.isAxiosError === true &&
+ !error.response &&
+ ['ERR_NETWORK', 'ECONNABORTED', 'ETIMEDOUT'].includes(
+ String(error.code),
+ ) &&
+ matchesFormRequest(error.config, requestId)
+ );
+}
diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts
index 8f133c5..d8029e5 100644
--- a/resources/js/locales/en.ts
+++ b/resources/js/locales/en.ts
@@ -43,6 +43,30 @@ export const en = {
'appearance.dark': 'Dark',
'appearance.system': 'System',
'common.cancel': 'Cancel',
+ 'common.close': 'Close',
+ 'common.retry': 'Try again',
+ 'feedback.empty': 'Nothing here yet',
+ 'feedback.saved': 'Changes saved',
+ 'forms.saving': 'Saving changes…',
+ 'forms.leaveConfirm': 'You have unsaved changes. Leave without saving?',
+ 'forms.validationTitle': 'Check your entries',
+ 'forms.validationDescription':
+ 'The server rejected some fields. Your entries are still here. Correct the highlighted errors and save again.',
+ 'forms.networkTitle': 'Connection interrupted',
+ 'forms.networkDescription':
+ 'Your entries are still here, but we could not confirm whether the server saved them. Check your connection and verify the result before trying again.',
+ 'forms.serverTitle': 'The request could not be completed',
+ 'forms.serverDescription':
+ 'Your entries are still here. The server returned an unexpected response; your session may have expired. Verify the result before trying again.',
+ 'forms.cancelledTitle': 'Saving was interrupted',
+ 'forms.cancelledDescription':
+ 'Your entries are still here. Check whether the changes were saved before trying again.',
+ 'tickets.noBranches': 'No matching branches',
+ 'tickets.noBranchesDescription':
+ 'No matching branches were found across the linked repositories.',
+ 'tickets.noPullRequests': 'No matching pull requests',
+ 'tickets.noPullRequestsDescription':
+ 'No matching pull requests were found across the linked repositories.',
'common.save': 'Save',
'common.loading': 'Loading…',
'common.error': 'Something went wrong.',
diff --git a/resources/js/pages/projects/Ticket.vue b/resources/js/pages/projects/Ticket.vue
index 0ea2610..65d2fdb 100644
--- a/resources/js/pages/projects/Ticket.vue
+++ b/resources/js/pages/projects/Ticket.vue
@@ -1,7 +1,14 @@
+
+
+
+
+
Dialogs and feedback
+
+
+
+
+
+ Leave page
+
+
+
+
+
+ Long detailsScrollable content on small
+ screens.
+ Detail {{ item }}
+
+
+
+
+
+
+ Sheet detailsFocus returns to the trigger.
+
+
+
+
+
+
+ Unavailable
+ Select action
+
+
+
{{ menuSelection }}
+
+
+
diff --git a/tests/Fixtures/feedback-browser.mjs b/tests/Fixtures/feedback-browser.mjs
new file mode 100644
index 0000000..e46bb1b
--- /dev/null
+++ b/tests/Fixtures/feedback-browser.mjs
@@ -0,0 +1,404 @@
+import assert from 'node:assert/strict';
+import process from 'node:process';
+
+const { chromium } = await import(
+ process.env.PLAYWRIGHT_MODULE ?? 'playwright'
+);
+const browser = await chromium.launch({ channel: 'chrome', headless: true });
+const base = 'http://127.0.0.1:4176';
+const context = await browser.newContext({
+ viewport: { width: 1280, height: 900 },
+});
+const page = await context.newPage();
+page.setDefaultTimeout(10000);
+const errors = [];
+page.on('pageerror', (error) => errors.push(error.message));
+const state = (value) =>
+ page
+ .locator(`[data-form-state="${value}"]`)
+ .first()
+ .waitFor({ state: 'visible' });
+const setResult = (value) =>
+ context.addCookies([{ name: 'feedback_test_result', value, url: base }]);
+const verify = async (condition) => {
+ assert.equal(await condition(), true);
+};
+const confirmNavigation = (accepted) =>
+ page.once('dialog', (dialog) =>
+ accepted ? dialog.accept() : dialog.dismiss(),
+ );
+
+try {
+ await page.goto(`${base}/feedback`);
+ await page.getByLabel('Title', { exact: true }).fill('Unsaved draft');
+ await verify(() =>
+ page.evaluate(() => {
+ const event = new Event('beforeunload', { cancelable: true });
+ window.dispatchEvent(event);
+ return event.defaultPrevented;
+ }),
+ );
+ confirmNavigation(false);
+ await page.getByRole('link', { name: 'Leave page' }).click();
+ assert.equal(new URL(page.url()).pathname, '/feedback');
+ assert.equal(
+ await page.getByLabel('Title', { exact: true }).inputValue(),
+ 'Unsaved draft',
+ );
+
+ await setResult('validation');
+ await page.getByRole('button', { name: 'Save draft' }).click();
+ await state('validation');
+ assert.equal(
+ await page.getByLabel('Title', { exact: true }).inputValue(),
+ 'Unsaved draft',
+ );
+ assert.equal(
+ await page
+ .getByLabel('Title', { exact: true })
+ .getAttribute('aria-invalid'),
+ 'true',
+ );
+ assert.equal(
+ await page.getByRole('button', { name: 'Try again' }).count(),
+ 0,
+ );
+
+ let failedRequests = 0;
+ await page.route('**/projects/7/tickets', (route) => {
+ failedRequests++;
+ return route.abort('failed');
+ });
+ await page.getByRole('button', { name: 'Save draft' }).click();
+ await state('network');
+ await page.waitForFunction(
+ () =>
+ document.activeElement?.getAttribute('data-form-state') ===
+ 'network',
+ );
+ assert.equal(
+ await page.getByLabel('Title', { exact: true }).inputValue(),
+ 'Unsaved draft',
+ );
+ assert.equal(failedRequests, 1);
+ await page.unroute('**/projects/7/tickets');
+ await setResult('server');
+ await page.getByRole('button', { name: 'Try again' }).click();
+ await state('server');
+ assert.equal(
+ await page.getByLabel('Title', { exact: true }).inputValue(),
+ 'Unsaved draft',
+ );
+
+ await setResult('success');
+ let pending;
+ let submissions = 0;
+ await page.route('**/projects/7/tickets', (route) => {
+ submissions++;
+ pending = route;
+ });
+ await page.getByRole('button', { name: 'Try again' }).click();
+ await state('loading');
+ await verify(() =>
+ page.evaluate(() => {
+ const event = new CustomEvent('inertia:exception', {
+ cancelable: true,
+ detail: { exception: new Error('Unrelated runtime error') },
+ });
+ document.dispatchEvent(event);
+ return !event.defaultPrevented;
+ }),
+ );
+ await verify(() =>
+ page.getByRole('button', { name: 'Save draft' }).isDisabled(),
+ );
+ await page.locator('form').evaluate((form) => form.requestSubmit());
+ assert.equal(submissions, 1);
+ await page.getByRole('button', { name: 'Cancel request' }).click();
+ await state('cancelled');
+ assert.equal(
+ await page.getByLabel('Title', { exact: true }).inputValue(),
+ 'Unsaved draft',
+ );
+ await pending.abort();
+ await page.unroute('**/projects/7/tickets');
+ await page.getByRole('button', { name: 'Try again' }).click();
+ await state('success');
+ await verify(() =>
+ page.evaluate(() => {
+ const event = new Event('beforeunload', { cancelable: true });
+ window.dispatchEvent(event);
+ return !event.defaultPrevented;
+ }),
+ );
+
+ await page.getByLabel('Title', { exact: true }).fill('History draft');
+ confirmNavigation(true);
+ await page.getByRole('link', { name: 'Leave page' }).click();
+ await page.getByRole('heading', { name: 'Destination' }).waitFor();
+ await page.goBack();
+ await page.getByLabel('Title', { exact: true }).waitFor();
+ assert.equal(
+ await page.getByLabel('Title', { exact: true }).inputValue(),
+ 'History draft',
+ );
+ await page.goForward();
+ await page.getByRole('heading', { name: 'Destination' }).waitFor();
+ await page.goBack();
+ await page.getByLabel('Title', { exact: true }).waitFor();
+ assert.equal(
+ await page.getByLabel('Title', { exact: true }).inputValue(),
+ 'History draft',
+ );
+ // Native history cannot be cancelled; remembered state preserves the draft.
+ await page.getByRole('button', { name: 'Save draft' }).click();
+ await state('success');
+
+ for (const width of [1280, 390]) {
+ await page.setViewportSize({ width, height: 844 });
+ for (const theme of ['Light', 'Dark']) {
+ console.log(`Checking dialogs: ${width}px, ${theme}`);
+ await page
+ .getByRole('button', { name: theme, exact: true })
+ .click();
+ await page.waitForFunction(
+ (dark) =>
+ document.documentElement.classList.contains('dark') ===
+ dark,
+ theme === 'Dark',
+ );
+ const opener = page.getByRole('button', {
+ name: 'Open dialog',
+ exact: true,
+ });
+ await opener.focus();
+ await page.keyboard.press('Enter');
+ await page.getByRole('dialog', { name: 'Edit details' }).waitFor();
+ await page
+ .getByRole('dialog', { name: 'Edit details' })
+ .evaluate((element) =>
+ Promise.all(
+ [
+ element,
+ ...document.querySelectorAll(
+ '[data-slot="dialog-overlay"]',
+ ),
+ ]
+ .flatMap((surface) => surface.getAnimations())
+ .map((animation) => animation.finished),
+ ),
+ );
+ assert.equal(
+ await page
+ .getByRole('dialog', { name: 'Edit details' })
+ .evaluate((element) => getComputedStyle(element).opacity),
+ '1',
+ );
+ await page.waitForFunction(() =>
+ document.activeElement?.closest('[role="dialog"]'),
+ );
+ for (let step = 0; step < 7; step++) {
+ await page.keyboard.press('Tab');
+ await verify(() =>
+ page.evaluate(() =>
+ Boolean(
+ document.activeElement?.closest('[role="dialog"]'),
+ ),
+ ),
+ );
+ }
+ await page.keyboard.press('Shift+Tab');
+ await verify(() =>
+ page.evaluate(() =>
+ Boolean(document.activeElement?.closest('[role="dialog"]')),
+ ),
+ );
+ await page.screenshot({
+ path: `/private/tmp/kan20-dialog-${width}-${theme.toLowerCase()}.png`,
+ });
+ await page
+ .getByRole('button', { name: 'Open nested dialog' })
+ .click();
+ await page
+ .getByRole('dialog', { name: 'Nested details' })
+ .waitFor();
+ await page.keyboard.press('Escape');
+ await page
+ .getByRole('dialog', { name: 'Nested details' })
+ .waitFor({ state: 'hidden' });
+ await page.waitForFunction(() =>
+ document.activeElement?.textContent.includes(
+ 'Open nested dialog',
+ ),
+ );
+ await page.keyboard.press('Escape');
+ await page
+ .getByRole('dialog', { name: 'Edit details' })
+ .waitFor({ state: 'hidden' });
+ await page.waitForFunction(
+ () => document.activeElement?.id === 'programmatic-trigger',
+ );
+ await opener.click();
+ await page
+ .getByRole('dialog', { name: 'Edit details' })
+ .getByRole('button', { name: 'Cancel', exact: true })
+ .click();
+ await page.waitForFunction(
+ () => document.activeElement?.id === 'programmatic-trigger',
+ );
+
+ const longOpener = page.getByRole('button', {
+ name: 'Open long dialog',
+ });
+ await longOpener.click();
+ const longDialog = page.getByRole('dialog', {
+ name: 'Long details',
+ });
+ await longDialog.waitFor();
+ const box = await longDialog.boundingBox();
+ assert.ok(box.x >= 0 && box.x + box.width <= width);
+ await longDialog
+ .getByRole('button', { name: 'Finish reading' })
+ .click();
+ await longDialog.waitFor({ state: 'hidden' });
+ await page.waitForFunction(() =>
+ document.activeElement?.textContent.includes(
+ 'Open long dialog',
+ ),
+ );
+
+ await page.getByRole('button', { name: 'Open sheet' }).click();
+ await page.getByRole('dialog', { name: 'Sheet details' }).waitFor();
+ await page.keyboard.press('Escape');
+ await page
+ .getByRole('dialog', { name: 'Sheet details' })
+ .waitFor({ state: 'hidden' });
+ await page.waitForFunction(() =>
+ document.activeElement?.textContent.includes('Open sheet'),
+ );
+
+ const actions = page.getByRole('button', { name: 'Open actions' });
+ await actions.focus();
+ await page.keyboard.press('Enter');
+ await page.getByRole('menu').waitFor();
+ await page
+ .getByRole('menu')
+ .evaluate((element) =>
+ Promise.all(
+ element
+ .getAnimations()
+ .map((animation) => animation.finished),
+ ),
+ );
+ const menuItem = await page
+ .getByRole('menuitem', { name: 'Select action' })
+ .boundingBox();
+ assert.ok(menuItem.height >= 44);
+ await page.keyboard.press('ArrowDown');
+ await page.keyboard.press('Enter');
+ await page.getByRole('menu').waitFor({ state: 'hidden' });
+ await page.waitForFunction(() =>
+ document.activeElement?.textContent.includes('Open actions'),
+ );
+ assert.equal(
+ await page.getByText('selected', { exact: true }).count(),
+ 1,
+ );
+ await verify(() =>
+ page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth,
+ ),
+ );
+ await page.screenshot({
+ path: `/private/tmp/kan20-feedback-${width}-${theme.toLowerCase()}.png`,
+ fullPage: true,
+ });
+ }
+ }
+
+ // Exercise the real ticket pages as well as the isolated components.
+ await page.goto(`${base}/projects/7/tickets/create`);
+ await page.locator('#title').fill('New ticket draft');
+ await page.locator('#description').fill('Keep this description');
+ await page
+ .locator('#acceptance_criteria_checklist')
+ .fill('[ ] Keep this checklist');
+ await setResult('validation');
+ await page.locator('form button[type="submit"]').first().click();
+ await state('validation');
+ assert.equal(
+ await page.locator('#acceptance_criteria_checklist').inputValue(),
+ '[ ] Keep this checklist',
+ );
+ await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
+ await page.locator('form button[type="submit"]').first().click();
+ await state('network');
+ assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
+ assert.equal(
+ await page.locator('#description').inputValue(),
+ 'Keep this description',
+ );
+ await page.unroute('**/projects/7/tickets');
+ confirmNavigation(true);
+ await page
+ .getByRole('link', { name: 'Back to backlog', exact: true })
+ .first()
+ .click();
+ await page.getByRole('heading', { name: 'Destination' }).waitFor();
+ await page.goBack();
+ await page.locator('#title').waitFor();
+ assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
+ assert.equal(
+ await page.locator('#acceptance_criteria_checklist').inputValue(),
+ '[ ] Keep this checklist',
+ );
+ await setResult('success');
+ await page.locator('form button[type="submit"]').first().click();
+ await page.getByRole('heading', { name: 'Destination' }).waitFor();
+
+ await page.goto(`${base}/projects/7/tickets/31`);
+ await page.locator('#title').fill('Updated ticket draft');
+ await page
+ .locator('#acceptance_criteria_checklist')
+ .fill('[ ] Edited checklist');
+ await setResult('server');
+ await page.locator('form button[type="submit"]').first().click();
+ await state('server');
+ assert.equal(
+ await page.locator('#title').inputValue(),
+ 'Updated ticket draft',
+ );
+ assert.equal(
+ await page.locator('#acceptance_criteria_checklist').inputValue(),
+ '[ ] Edited checklist',
+ );
+ await setResult('success');
+ await page.getByRole('button', { name: 'Try again' }).click();
+ await state('success');
+ await verify(() =>
+ page.evaluate(() => {
+ const event = new Event('beforeunload', { cancelable: true });
+ window.dispatchEvent(event);
+ return !event.defaultPrevented;
+ }),
+ );
+ assert.deepEqual(errors, []);
+ console.log(
+ 'Feedback browser behavior checks passed (desktop/mobile, light/dark, real ticket forms).',
+ );
+} catch (error) {
+ console.error(
+ 'Active element:',
+ await page.evaluate(() =>
+ document.activeElement?.outerHTML.slice(0, 500),
+ ),
+ );
+ console.error('Browser errors:', errors);
+ await page.screenshot({
+ path: '/private/tmp/kan20-feedback-failure.png',
+ fullPage: true,
+ });
+ throw error;
+} finally {
+ await browser.close();
+}
diff --git a/tests/Fixtures/feedback-preview.mjs b/tests/Fixtures/feedback-preview.mjs
new file mode 100644
index 0000000..6843547
--- /dev/null
+++ b/tests/Fixtures/feedback-preview.mjs
@@ -0,0 +1,26 @@
+import { createApp, h } from 'vue';
+import { createInertiaApp, Link } from '@inertiajs/vue3';
+import '../../resources/css/app.css';
+import FeedbackPreview from './FeedbackPreview.vue';
+import TicketCreate from '../../resources/js/pages/projects/TicketCreate.vue';
+import Ticket from '../../resources/js/pages/projects/Ticket.vue';
+import { createAppearance } from '../../resources/js/composables/useAppearance';
+import { createLocalization } from '../../resources/js/lib/localization';
+
+const Destination = {
+ render: () =>
+ h('main', [
+ h('h1', 'Destination'),
+ h(Link, { href: '/feedback' }, () => 'Feedback preview'),
+ ]),
+};
+createInertiaApp({
+ resolve: (name) =>
+ ({ FeedbackPreview, TicketCreate, Ticket, Destination })[name],
+ setup: ({ el, App, props, plugin }) =>
+ createApp({ render: () => h(App, props) })
+ .use(plugin)
+ .use(createLocalization())
+ .use(createAppearance())
+ .mount(el),
+});
diff --git a/tests/Fixtures/feedback-server.mjs b/tests/Fixtures/feedback-server.mjs
new file mode 100644
index 0000000..688cab6
--- /dev/null
+++ b/tests/Fixtures/feedback-server.mjs
@@ -0,0 +1,159 @@
+import { fileURLToPath } from 'node:url';
+import vue from '@vitejs/plugin-vue';
+import tailwind from '@tailwindcss/vite';
+import { createServer } from 'vite';
+
+const server = await createServer({
+ configFile: false,
+ plugins: [vue(), tailwind()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
+ },
+ },
+ server: { host: '127.0.0.1', port: 4176, strictPort: true, hmr: false },
+ appType: 'custom',
+});
+const project = {
+ id: 7,
+ name: 'Engineering',
+ key: 'ENG',
+ show_author: true,
+ show_story_points: true,
+};
+const ticketType = { id: 1, name: 'Task', is_parent: false };
+const priority = { id: 1, name: 'Medium' };
+const status = { id: 1, name: 'To do', board_column: null };
+let ticket = {
+ id: 31,
+ ticket_key: 'ENG-31',
+ title: 'Existing ticket',
+ description: '',
+ ticket_type: ticketType,
+ ticket_type_id: 1,
+ ticket_priority: priority,
+ ticket_priority_id: 1,
+ ticket_status: status,
+ is_blocked: false,
+ story_points: 0,
+ acceptance_criteria: [],
+ field_values: [],
+ worklog_entries: [],
+};
+
+server.middlewares.use(async (request, response, next) => {
+ const url = new URL(request.url, 'http://localhost');
+ if (!/^\/(feedback|projects|dashboard)(\/|$)/.test(url.pathname)) {
+ return next();
+ }
+ let path = url.pathname;
+ const mutation = request.method !== 'GET';
+ const result =
+ request.headers.cookie?.match(/feedback_test_result=(\w+)/)?.[1] ??
+ 'success';
+ if (path.endsWith('/branches/validate')) {
+ response.setHeader('Content-Type', 'application/json');
+ response.end(JSON.stringify({ is_valid: true, message: 'Available' }));
+ return;
+ }
+ if (mutation) {
+ if (result === 'server') {
+ response.statusCode = 503;
+ response.setHeader('Content-Type', 'text/html');
+ response.end('Temporarily unavailable
');
+ return;
+ }
+ path = new URL(request.headers.referer).pathname;
+ let body = '';
+ for await (const chunk of request) {
+ body += chunk;
+ }
+ if (path === '/projects/7/tickets/31' && result === 'success') {
+ ticket = { ...ticket, ...JSON.parse(body) };
+ }
+ if (path === '/projects/7/tickets/create' && result === 'success') {
+ path = '/projects/7/backlog';
+ }
+ }
+ const page = {
+ component:
+ path === '/feedback'
+ ? 'FeedbackPreview'
+ : path.endsWith('/tickets/create')
+ ? 'TicketCreate'
+ : path === '/projects/7/tickets/31'
+ ? 'Ticket'
+ : 'Destination',
+ url: path,
+ version: null,
+ clearHistory: false,
+ encryptHistory: false,
+ props: {
+ name: 'KanbanGit',
+ sidebarOpen: true,
+ auth: {
+ permissions: {
+ globalAdmin: true,
+ projectCreate: true,
+ canManageTeams: true,
+ },
+ user: {
+ id: 1,
+ name: 'Alex Morgan',
+ email: 'alex@example.test',
+ avatar: null,
+ },
+ },
+ navigation: {
+ project: {
+ ...project,
+ canManage: true,
+ canCreateTickets: true,
+ },
+ },
+ errors:
+ mutation && result === 'validation'
+ ? { title: 'Use a different title.' }
+ : {},
+ project,
+ source: 'backlog',
+ defaultTicketStatusId: 1,
+ ticketTypes: [ticketType],
+ ticketPriorities: [priority],
+ ticketStatusOptions: [status],
+ users: [],
+ parentTicketOptions: [],
+ canEditTicket: true,
+ canManageProject: true,
+ childTicketTree: [],
+ linkedTickets: [],
+ repositoryBranches: [],
+ repositoryPullRequests: [],
+ suggestedBranchName: 'feature/ENG-31-existing-ticket',
+ ticket,
+ ticketFieldDefinitions: [],
+ ticketLinkOptions: [],
+ ticketLinkTypes: [],
+ },
+ };
+ response.setHeader('Vary', 'X-Inertia');
+ if (request.headers['x-inertia']) {
+ response.setHeader('X-Inertia', 'true');
+ response.setHeader('Content-Type', 'application/json');
+ response.end(JSON.stringify(page));
+ return;
+ }
+ const escapedPage = JSON.stringify(page)
+ .replaceAll('&', '&')
+ .replaceAll('"', '"')
+ .replaceAll('<', '<');
+ const html =
+ '';
+ response.setHeader('Content-Type', 'text/html');
+ response.end(await server.transformIndexHtml(request.url, html));
+});
+
+await server.listen();
+console.log('Feedback fixture ready at http://127.0.0.1:4176/feedback');
diff --git a/tests/Fixtures/form-feedback.mjs b/tests/Fixtures/form-feedback.mjs
new file mode 100644
index 0000000..10aa11b
--- /dev/null
+++ b/tests/Fixtures/form-feedback.mjs
@@ -0,0 +1,129 @@
+import assert from 'node:assert/strict';
+import { fileURLToPath } from 'node:url';
+import vue from '@vitejs/plugin-vue';
+import { createServer } from 'vite';
+import { createSSRApp, h } from 'vue';
+import { renderToString } from 'vue/server-renderer';
+
+const server = await createServer({
+ configFile: false,
+ plugins: [vue()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
+ },
+ },
+ server: { middlewareMode: true, hmr: false, watch: null },
+ appType: 'custom',
+});
+
+try {
+ const { isFormNetworkFailure, matchesFormRequest, formRequestHeader } =
+ await server.ssrLoadModule('/resources/js/lib/form-feedback.ts');
+ const config = { headers: { [formRequestHeader]: 'submission-a' } };
+ for (const code of ['ERR_NETWORK', 'ECONNABORTED', 'ETIMEDOUT']) {
+ assert.equal(
+ isFormNetworkFailure(
+ { isAxiosError: true, code, config },
+ 'submission-a',
+ ),
+ true,
+ );
+ }
+ for (const error of [
+ null,
+ new Error('Render failed'),
+ { code: 'ERR_NETWORK', config },
+ { isAxiosError: true, code: 'ERR_CANCELED', config },
+ {
+ isAxiosError: true,
+ code: 'ERR_NETWORK',
+ config,
+ response: { status: 500 },
+ },
+ { isAxiosError: true, code: 'ERR_NETWORK', config: {} },
+ ]) {
+ assert.equal(isFormNetworkFailure(error, 'submission-a'), false);
+ }
+ assert.equal(
+ isFormNetworkFailure(
+ { isAxiosError: true, code: 'ERR_NETWORK', config },
+ 'submission-b',
+ ),
+ false,
+ );
+ assert.equal(matchesFormRequest(config, null), false);
+ assert.equal(matchesFormRequest(config, 'submission-a'), true);
+ assert.equal(matchesFormRequest(undefined, 'submission-a'), false);
+
+ const { createLocalization } = await server.ssrLoadModule(
+ '/resources/js/lib/localization.ts',
+ );
+ const { en } = await server.ssrLoadModule('/resources/js/locales/en.ts');
+ const { default: FeedbackState } = await server.ssrLoadModule(
+ '/resources/js/components/FeedbackState.vue',
+ );
+ const { default: FormFeedback } = await server.ssrLoadModule(
+ '/resources/js/components/FormFeedback.vue',
+ );
+ const { default: InputError } = await server.ssrLoadModule(
+ '/resources/js/components/InputError.vue',
+ );
+ const localization = createLocalization(
+ { locale: 'de' },
+ {
+ de: {
+ ...en,
+ 'common.retry': 'Erneut versuchen',
+ 'feedback.empty': 'Noch keine Einträge',
+ },
+ },
+ );
+ const render = (component, props) =>
+ renderToString(
+ createSSRApp({ render: () => h(component, props) }).use(
+ localization,
+ ),
+ );
+ assert.match(
+ await render(FeedbackState, { state: 'empty' }),
+ /Noch keine Einträge/,
+ );
+ const loading = await render(FormFeedback, { state: 'loading' });
+ assert.match(loading, /role="status"/);
+ assert.match(loading, /aria-busy="true"/);
+ assert.match(loading, /motion-reduce:animate-none/);
+ assert.doesNotMatch(loading, /