From 1333333ce3ebcd64ccb72e861af78e81c73ee7f3 Mon Sep 17 00:00:00 2001 From: ichwars Date: Tue, 8 Sep 2026 16:07:44 +0200 Subject: [PATCH 1/2] fix(ui): make card actions touch and keyboard accessible --- .github/workflows/ci.yml | 5 +- CHANGELOG.md | 2 + docs/README.md | 1 + docs/touch-and-keyboard-controls.md | 42 ++++ frontend/e2e/touch-actions.spec.ts | 225 ++++++++++++++++++ frontend/package-lock.json | 7 +- frontend/playwright.production.config.ts | 13 + .../__tests__/components/ActionMenu.test.tsx | 89 +++++++ .../__tests__/components/ContextMenu.test.tsx | 37 ++- .../components/FolderActions.test.tsx | 40 ++++ .../pages/FileManagerSliceFormats.test.tsx | 46 ++++ .../pages/TouchReachableActions.test.ts | 34 +++ frontend/src/components/ContextMenu.tsx | 34 ++- frontend/src/components/EditArchiveModal.tsx | 3 +- .../src/components/TagManagementModal.tsx | 2 +- frontend/src/components/ui/ActionMenu.tsx | 95 ++++++++ frontend/src/index.css | 4 + frontend/src/pages/ArchivesPage.tsx | 28 +-- frontend/src/pages/FileManagerPage.tsx | 123 +++------- frontend/src/pages/ProfilesPage.tsx | 2 +- frontend/src/pages/ProjectsPage.tsx | 29 +-- .../src/pages/file-manager/FolderActions.tsx | 59 +++++ .../pages/printers/PrinterCardOverlays.tsx | 2 +- tools/check_source_size_budget.py | 4 +- 24 files changed, 790 insertions(+), 136 deletions(-) create mode 100644 docs/touch-and-keyboard-controls.md create mode 100644 frontend/e2e/touch-actions.spec.ts create mode 100644 frontend/playwright.production.config.ts create mode 100644 frontend/src/__tests__/components/ActionMenu.test.tsx create mode 100644 frontend/src/__tests__/components/FolderActions.test.tsx create mode 100644 frontend/src/__tests__/pages/TouchReachableActions.test.ts create mode 100644 frontend/src/components/ui/ActionMenu.tsx create mode 100644 frontend/src/pages/file-manager/FolderActions.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b3a5b2eed..27b137dc4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -333,11 +333,11 @@ jobs: working-directory: frontend run: npm run build - - name: Install browser for production README smoke test + - name: Install browser for production UI smoke tests working-directory: frontend run: npx playwright install --with-deps chromium - - name: Verify production README in Chromium + - name: Verify production README and touch actions in Chromium working-directory: frontend run: | npm run preview -- --host 127.0.0.1 --port 4184 --strictPort & @@ -348,6 +348,7 @@ jobs: sleep 1 done npm run check:browser-runtime + npx playwright test --config playwright.production.config.ts # ============================================================================ # Docker Tests (matches test_docker.sh) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f41e37bfa..a86256ab1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to PrintOps will be documented in this file. ## Unreleased ### Fixed +- **Build dependency audit** — Updated the transitive development dependency `nanoid` from 3.3.16 to 3.3.18 to address GHSA-2v37-7h3g-55p8; application dependencies are unchanged. +- **Touch and keyboard access to secondary actions (#145)** — Card and row actions remain visible on touch devices, including wide tablets, and reveal on keyboard focus on desktops. File, folder and project menus escape clipping containers, remain within the viewport and support keyboard navigation. The compact File Manager folder selector now exposes the same permission-checked actions as the desktop folder tree. See [touch and keyboard controls](docs/touch-and-keyboard-controls.md). - **Safari 16.0–16.3 File Manager loading (#144)** — Removed the GFM autolink-literal import that could prevent the lazy File Manager chunk from parsing. Tables, task lists, strikethrough, footnotes and explicit links remain available; bare URLs/emails now stay text. Production builds target Safari 16 and check emitted JavaScript, including the transformed PDF worker, for known incompatible syntax. PDF preview runtime support still requires a current browser; see [browser compatibility](docs/browser-compatibility.md). - **AMS material names, colours and RFID tare weights stay consistent (#137)** — AMS writes now keep product and subtype labels for display while sending printer-supported material types, preserve preset filament types, normalize tray colours at the MQTT boundary, and retain assignments when normalized telemetry returns. Newly detected Bambu RFID spools use the named low-temperature core with its catalog link; a conservative one-time repair corrects only untouched auto-detected legacy rows and preserves manual edits. - **FTPS retries and downloads remain bounded without wasting healthy work (#136)** — Explicit print and firmware uploads now make their own bounded connection attempts instead of spending every retry on a background handshake cooldown, while background cover and archive scans retain the cooldown guard. Slow downloads receive a capped size-based total budget in addition to the socket inactivity timeout, heavy 3MF transfers take turns per printer, timed-out path searches cooperatively stop their worker, and cover retries re-check the shared 3MF cache. Upload failures now distinguish TLS, authentication, network, path and SD-card/storage causes instead of blaming storage for every failure. diff --git a/docs/README.md b/docs/README.md index 9e416d14be..6356a61c9e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,7 @@ Screenshots gehören nicht in das Repository. | Betrieb | [H2C-Nozzle-Mapping-Audit](h2c-nozzle-mapping-dispatch-audit.md) | Software-Sicherheitsgrenze und Hardware-Freigabenachweis für physische H2C-Düsen-IDs | | Entwicklung | [README Editorial Guide](readme-editorial-guide.md) | Zielgruppen, Positionierung und Regeln für die zentrale Projekt-README | | Browser | [Browser-Kompatibilität](browser-compatibility.md) | Safari-16-Syntaxbaseline, README-Markdown und Grenzen der PDF-Vorschau | +| Bedienung | [Touch und Tastatur](touch-and-keyboard-controls.md) | Sichtbare Karten-/Zeilenaktionen, Ordnermenüs und Fokusnavigation | Die allgemeine Projektübersicht, Installation und erste Schritte stehen in der [README im Projektstamm](../README.md). diff --git a/docs/touch-and-keyboard-controls.md b/docs/touch-and-keyboard-controls.md new file mode 100644 index 0000000000..6ca9764c05 --- /dev/null +++ b/docs/touch-and-keyboard-controls.md @@ -0,0 +1,42 @@ +# Touch- und Tastaturbedienung + +Sekundäre Aktionen an Karten und Zeilen bleiben bei einem Eingabegerät ohne +präzisen Hover-Pointer sichtbar. Das gilt auch für breite Tablets: Nicht die +Fensterbreite, sondern die Eingabefähigkeit entscheidet. Mit einer Maus werden +die Aktionen beim Überfahren eingeblendet; Tastaturfokus blendet sie ebenfalls +ein. + +Das betrifft Projekt- und Dateikarten, Ordnerzeilen, Archivaktionen und +Plattennavigation, das Duplizieren von Profilvorlagen, die Tagverwaltung sowie +das Entfernen von Archivfotos und Plattenreferenzen. Bestehende Berechtigungen +gelten unverändert; eine sichtbare Aktion ist keine zusätzliche Berechtigung. + +## Aktionsmenüs + +Im Dateimanager und bei Projekten öffnet die Drei-Punkte-Schaltfläche das Menü. +Auf kleinen Displays steht das Ordnermenü neben der Ordnerauswahl und bezieht +sich auf den ausgewählten Ordner, nicht auf die übergeordneten Sammelansichten. + +- Mit Tab zur Schaltfläche wechseln; Enter, Leertaste oder Pfeiltaste öffnen + das Menü. +- Pfeil nach oben/unten wechselt zwischen ausführbaren Aktionen, Pos1/Ende zur + ersten/letzten. Enter oder Leertaste führt die fokussierte Aktion aus. +- Escape schließt und setzt den Fokus auf die Schaltfläche zurück. Tab verlässt + das Menü in der normalen Reihenfolge der Seite. +- Außenklick oder Scrollen der Seite schließt das Menü. Ein langes Menü darf + intern scrollen, ohne sich zu schließen. + +Menüs werden außerhalb der Karten-/Seitenleistenbegrenzung dargestellt und +passen ihre Position und maximale Höhe an den verfügbaren Platz an. + +## Entwicklung und Regressionen + +Hoverabhängiges Ausblenden verwendet die CSS-Variante `can-hover` zusammen mit +einer Fokus-Einblendung. Dekorative Overlays sind davon getrennt zu beurteilen. +Verankerte flache Aktionsmenüs verwenden `ActionMenu` und die bestehende +Positionierung von `FloatingLayer`; koordinatenbasierte Kontextmenüs verwenden +weiterhin `ContextMenu`. + +`npx playwright test --config playwright.production.config.ts` prüft einen +bereits gestarteten Produktions-Preview auf Port 4184. Die Konfiguration baut +nicht selbst und überschreibt daher keine lokalen `static/`-Dateien. diff --git a/frontend/e2e/touch-actions.spec.ts b/frontend/e2e/touch-actions.spec.ts new file mode 100644 index 0000000000..fdba910116 --- /dev/null +++ b/frontend/e2e/touch-actions.spec.ts @@ -0,0 +1,225 @@ +import { expect, test, type Locator, type Page } from '@playwright/test'; + +async function installLibrary(page: Page) { + await page.addInitScript(() => localStorage.setItem('bambutrack_language', 'en')); + await page.route('**/sw-register.js', route => route.fulfill({ contentType: 'application/javascript', body: '' })); + await page.route('**/api/v1/**', route => { + const path = new URL(route.request().url()).pathname.replace(/\/+$/, ''); + let data: unknown = []; + if (path === '/api/v1/auth/status') data = { auth_enabled: false, requires_setup: false }; + else if (path === '/api/v1/auth/encryption-status') data = { + key_configured: true, key_source: 'generated', decryption_broken: false, migration_error_count: 0, + legacy_plaintext_rows: { oidc_providers: 0, user_totp: 0 }, encrypted_rows: { oidc_providers: 0, user_totp: 0 }, + }; + else if (path === '/api/v1/settings') data = { use_slicer_api: true, library_disk_warning_gb: 5 }; + else if (path === '/api/v1/library/folders') data = [{ id: 42, name: 'Touch models', children: [], file_count: 1, parent_id: null }]; + else if (path === '/api/v1/library/files') data = [{ + id: 7, filename: 'gear.stl', file_type: 'stl', file_size: 1024, folder_id: 42, + created_at: '2026-09-08T00:00:00Z', print_count: 0, tags: [], + }]; + else if (path === '/api/v1/library/folders/42/readme') data = { filename: 'README.md', content: 'Touch fixture', truncated: false }; + else if (path === '/api/v1/library/trash') data = { items: [], total: 0 }; + else if (path === '/api/v1/library/stats') data = { total_files: 1, total_folders: 1, total_size_bytes: 1024, disk_free_bytes: 10e9, disk_total_bytes: 100e9 }; + else if (path === '/api/v1/projects') data = [{ id: 11, name: 'Touch project', color: '#00ae42', archive_count: 0, completed_count: 0, failed_count: 0, queue_count: 0 }]; + else if (path === '/api/v1/archives') data = [{ + id: 21, filename: 'gear.gcode.3mf', print_name: 'Touch archive', file_path: '/fixture.3mf', + thumbnail_path: '/fixture.png', status: 'completed', tags: 'test', print_count: 1, + created_at: '2026-09-08T00:00:00Z', print_time_seconds: 120, filament_used_grams: 2, + }]; + else if (path === '/api/v1/archives/stats') data = { total_archives: 1, total_print_time_seconds: 120, total_filament_grams: 2 }; + else if (path === '/api/v1/archives/tags') data = [{ name: 'test', count: 1 }]; + else if (path === '/api/v1/archives/21/plates') data = { archive_id: 21, is_multi_plate: true, plates: [{ index: 1, name: 'First plate' }, { index: 2, name: 'Second plate' }] }; + else if (path.includes('/thumbnail')) return route.fulfill({ contentType: 'image/svg+xml', body: '' }); + return route.fulfill({ contentType: 'application/json', body: JSON.stringify(data) }); + }); +} + +for (const touch of [true, false]) { + test.describe(`project/archive ${touch ? 'touch' : 'keyboard'}`, () => { + test.use({ viewport: { width: touch ? 1024 : 1440, height: 1000 }, hasTouch: touch }); + test('project menu, archive plates and tag actions do not require hover', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await installLibrary(page); + await page.goto('/projects'); + const projectTrigger = page.getByRole('button', { name: 'Actions: Touch project' }); + if (touch) { + await expect.poll(() => opacity(projectTrigger)).toBe(1); + await projectTrigger.tap(); + } else await projectTrigger.press('Enter'); + const projectMenu = page.getByRole('menu', { name: 'Actions: Touch project' }); + await expect(projectMenu.getByRole('menuitem', { name: 'Edit' })).toBeFocused(); + await hitTarget(projectMenu.getByRole('menuitem', { name: 'Delete' })); + await page.keyboard.press('Escape'); + await expect(projectTrigger).toBeFocused(); + + await page.goto('/archives'); + const card = page.locator('[data-archive-id="21"]'); + const options = card.getByRole('button', { name: 'More options' }); + if (touch) { + await expect.poll(() => opacity(options)).toBe(1); + await card.getByRole('img', { name: 'Touch archive' }).tap(); + } else { + await options.focus(); + await expect.poll(() => opacity(options)).toBe(1); + } + const nextPlate = card.getByRole('button', { name: 'Next plate' }); + await expect(nextPlate).toBeVisible(); + if (touch) { + await expect.poll(() => opacity(nextPlate)).toBe(1); + await nextPlate.tap(); + } else await nextPlate.press('Enter'); + await expect(card.getByRole('img', { name: 'Touch archive' })).toHaveAttribute('src', /plate.*2|2.*thumbnail/); + await options.press('Enter'); + await expect(page.getByRole('button', { name: 'Print', exact: true }).last()).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(options).toBeFocused(); + + await page.getByRole('button', { name: 'Manage Tags', exact: true }).click(); + const rename = page.getByRole('button', { name: 'Rename tag' }); + if (touch) { + await expect.poll(() => opacity(rename)).toBe(1); + await rename.tap(); + } else { + await rename.focus(); + await expect.poll(() => opacity(rename)).toBe(1); + await rename.press('Enter'); + } + await expect(page.locator('input:focus')).toHaveValue('test'); + expect(errors).toEqual([]); + }); + }); +} + +async function opacity(control: Locator) { + return control.evaluate(element => { + let opacity = 1; + for (let node: Element | null = element; node; node = node.parentElement) opacity *= Number(getComputedStyle(node).opacity); + return opacity; + }); +} + +async function hitTarget(control: Locator) { + await expect.poll(() => control.evaluate(element => { + const rect = element.getBoundingClientRect(); + return element.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)); + })).toBe(true); +} + +test.describe('short touch viewport', () => { + test.use({ viewport: { width: 390, height: 200 }, hasTouch: true }); + test('a constrained menu scrolls internally without dismissing', async ({ page }) => { + await installLibrary(page); + await page.goto('/files?folder=42'); + await page.getByRole('button', { name: 'Actions: gear.stl', exact: true }).tap(); + const menu = page.getByRole('menu', { name: 'Actions: gear.stl' }); + const layer = menu.locator('..'); + await expect.poll(() => layer.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true); + await page.keyboard.press('End'); + await hitTarget(menu.getByRole('menuitem', { name: 'Delete', exact: true })); + await expect.poll(() => layer.evaluate(el => el.scrollTop)).toBeGreaterThan(0); + await page.keyboard.press('Home'); + await hitTarget(menu.getByRole('menuitem', { name: 'Slice', exact: true })); + await page.keyboard.press('Escape'); + await expect(menu).toHaveCount(0); + }); +}); + +for (const viewport of [{ width: 390, height: 320 }, { width: 390, height: 844 }, { width: 1024, height: 844 }, { width: 1440, height: 1000 }]) { + const touch = viewport.width < 1440; + test.describe(`${viewport.width}x${viewport.height} ${touch ? 'touch' : 'mouse'}`, () => { + test.use({ viewport, hasTouch: touch }); + + test('file/folder actions are reachable, unclipped and keyboard-operable', async ({ page }, testInfo) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { + if (message.type() === 'error' && !/^(?:WebSocket connection.*failed|\[WebSocket\] Error)/i.test(message.text())) errors.push(message.text()); + }); + await installLibrary(page); + await page.goto('/files?folder=42'); + await expect(page).toHaveTitle(/PrintOps/i); + await expect(page.getByRole('heading', { name: 'gear.stl' })).toBeVisible(); + await expect(page.locator('vite-error-overlay')).toHaveCount(0); + const trigger = page.getByRole('button', { name: 'Actions: gear.stl', exact: true }); + await trigger.scrollIntoViewIfNeeded(); + if (touch) { + expect(await page.evaluate(() => matchMedia('(hover: none)').matches)).toBe(true); + await expect.poll(() => opacity(trigger)).toBe(1); + await trigger.tap(); + } else { + await page.mouse.move(0, 0); + await expect.poll(() => opacity(trigger)).toBe(0); + await trigger.hover(); + await expect.poll(() => opacity(trigger)).toBe(1); + await page.mouse.move(0, 0); + // Reach the trigger using only Tab, not simulated hover or DOM focus. + for (let i = 0; i < 100 && !(await trigger.evaluate(el => el === document.activeElement)); i++) await page.keyboard.press('Tab'); + await expect(trigger).toBeFocused(); + await expect.poll(() => opacity(trigger)).toBe(1); + await page.keyboard.press('Enter'); + } + const menu = page.getByRole('menu', { name: 'Actions: gear.stl' }); + const first = menu.getByRole('menuitem').first(); + await expect(first).toHaveText('Slice'); + await expect(first).toBeFocused(); + await hitTarget(first); + await expect.poll(() => opacity(menu)).toBe(1); + await expect.poll(() => opacity(trigger)).toBe(1); + const layer = menu.locator('..'); + await expect.poll(async () => { + const rect = await layer.boundingBox(); + return Boolean(rect && rect.y >= 7 && rect.y + rect.height <= viewport.height - 7); + }).toBe(true); + const box = await menu.boundingBox(); + expect(box!.x).toBeGreaterThanOrEqual(7); + expect(box!.x + box!.width).toBeLessThanOrEqual(viewport.width - 7); + expect(box!.y).toBeGreaterThanOrEqual(7); + await page.screenshot({ path: testInfo.outputPath('file-menu.png') }); + await page.keyboard.press('End'); + await expect(menu.getByRole('menuitem', { name: 'Delete', exact: true })).toBeFocused(); + await hitTarget(menu.getByRole('menuitem', { name: 'Delete', exact: true })); + await expect(menu).toBeVisible(); + await page.keyboard.press('Home'); + await expect(first).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(menu).toHaveCount(0); + await expect(trigger).toBeFocused(); + await page.keyboard.press('Enter'); + await page.keyboard.press('Tab'); + await expect(menu).toHaveCount(0); + expect(await page.evaluate(() => document.activeElement?.tagName)).not.toBe('BODY'); + await trigger.press('Enter'); + await menu.getByRole('menuitem', { name: 'Rename', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Rename File' })).toBeVisible(); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + + const folderTrigger = page.getByRole('button', { name: 'Actions: Touch models', exact: true }); + await folderTrigger.scrollIntoViewIfNeeded(); + if (touch) { + await expect.poll(() => opacity(folderTrigger)).toBe(1); + await folderTrigger.tap(); + } else await folderTrigger.press('Enter'); + const folderMenu = page.getByRole('menu', { name: 'Actions: Touch models' }); + await hitTarget(folderMenu.getByRole('menuitem', { name: 'Rename' })); + await page.keyboard.press('Escape'); + await expect(folderTrigger).toBeFocused(); + if (!touch) { + // The action popup must not leave an overlay intercepting file drops. + const transfer = await page.evaluateHandle(() => { + const data = new DataTransfer(); + data.items.add(new File(['solid gear\nendsolid gear'], 'drop.stl', { type: 'application/sla' })); + return data; + }); + const card = page.getByRole('heading', { name: 'gear.stl' }).locator('../..'); + await card.dispatchEvent('dragenter', { dataTransfer: transfer }); + await card.dispatchEvent('dragover', { dataTransfer: transfer }); + await card.dispatchEvent('drop', { dataTransfer: transfer }); + await expect(page.getByRole('heading', { name: 'Upload Files' })).toBeVisible(); + await expect(page.getByText('drop.stl', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + } + expect(errors).toEqual([]); + }); + }); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ff0bd965e0..ba87bfe06e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6574,9 +6574,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -6584,6 +6584,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, diff --git a/frontend/playwright.production.config.ts b/frontend/playwright.production.config.ts new file mode 100644 index 0000000000..8ac51c58c7 --- /dev/null +++ b/frontend/playwright.production.config.ts @@ -0,0 +1,13 @@ +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import base from './playwright.config'; + +// Exercise an already-built preview. Never rebuild ../static as a side effect +// of a production smoke test (it may contain the developer's own changes). +export default { + ...base, + webServer: undefined, + testMatch: 'touch-actions.spec.ts', + outputDir: process.env.PLAYWRIGHT_OUTPUT_DIR || path.join(tmpdir(), 'printops-production-tests'), + use: { ...base.use, baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:4184' }, +}; diff --git a/frontend/src/__tests__/components/ActionMenu.test.tsx b/frontend/src/__tests__/components/ActionMenu.test.tsx new file mode 100644 index 0000000000..019154f6e3 --- /dev/null +++ b/frontend/src/__tests__/components/ActionMenu.test.tsx @@ -0,0 +1,89 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { ActionMenu } from '../../components/ui/ActionMenu'; + +function setup(disabled = false) { + const action = vi.fn(); + const parentClick = vi.fn(); + render(<> + +
+ + {(close) => <> + + + + } + +
+ + ); + return { action, parentClick, user: userEvent.setup(), trigger: screen.getByRole('button', { name: 'File actions' }) }; +} + +describe('ActionMenu', () => { + it('portals outside clipping ancestors, focuses the first item and restores focus on Escape', async () => { + const { user, trigger, parentClick } = setup(); + await user.click(trigger); + const menu = screen.getByRole('menu'); + expect(menu.parentElement?.parentElement).toBe(document.body); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('menuitem', { name: 'Edit' })).toHaveFocus(); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + expect(parentClick).not.toHaveBeenCalled(); + }); + + it('supports arrows, Home/End, skips forbidden actions and activates with Enter', async () => { + const { user, trigger, action, parentClick } = setup(); + trigger.focus(); + await user.keyboard('{ArrowUp}'); + expect(screen.getByRole('menuitem', { name: 'Download' })).toHaveFocus(); + await user.keyboard('{ArrowDown}'); + expect(screen.getByRole('menuitem', { name: 'Edit' })).toHaveFocus(); + await user.keyboard('{End}{ArrowUp}{Home}{Enter}'); + expect(action).toHaveBeenCalledOnce(); + expect(trigger).toHaveFocus(); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(parentClick).not.toHaveBeenCalled(); + }); + + it('restores the trigger and leaves native Tab/Shift+Tab navigation unprevented', async () => { + const { user, trigger } = setup(); + await user.click(trigger); + // user-event calculates Tab's destination from the original key target, + // not the focus moved in the handler. Real Tab navigation is covered by + // the production Playwright test; here assert the handoff contract. + expect(fireEvent.keyDown(screen.getByRole('menuitem', { name: 'Edit' }), { key: 'Tab' })).toBe(true); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + await user.click(trigger); + expect(fireEvent.keyDown(screen.getByRole('menuitem', { name: 'Edit' }), { key: 'Tab', shiftKey: true })).toBe(true); + expect(trigger).toHaveFocus(); + }); + + it('keeps disabled actions disabled and closes on an outside pointer without stealing focus', async () => { + const { user, trigger, action } = setup(true); + await user.click(trigger); + expect(screen.getByRole('menuitem', { name: 'Download' })).toHaveFocus(); + await user.click(screen.getByRole('menuitem', { name: 'Edit' })); + expect(action).not.toHaveBeenCalled(); + expect(screen.getByRole('menu')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'After' })); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'After' })).toHaveFocus(); + }); + + it('allows internal scrolling but dismisses on page scroll and cleans up listeners', async () => { + const { user, trigger } = setup(); + await user.click(trigger); + fireEvent.scroll(screen.getByRole('menu').parentElement!); + expect(screen.getByRole('menu')).toBeInTheDocument(); + fireEvent.scroll(document); + await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument()); + await user.click(trigger); + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/components/ContextMenu.test.tsx b/frontend/src/__tests__/components/ContextMenu.test.tsx index 86a515231f..226303ddcd 100644 --- a/frontend/src/__tests__/components/ContextMenu.test.tsx +++ b/frontend/src/__tests__/components/ContextMenu.test.tsx @@ -3,7 +3,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { screen } from '@testing-library/react'; +import { fireEvent, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { render } from '../utils'; import { ContextMenu } from '../../components/ContextMenu'; @@ -53,6 +53,41 @@ describe('ContextMenu', () => { }); describe('interactions', () => { + it('focuses enabled actions, supports arrows and restores the opener on Escape', async () => { + const user = userEvent.setup(); + const opener = document.createElement('button'); + document.body.append(opener); + opener.focus(); + const { unmount } = render(); + expect(screen.getByRole('button', { name: 'Edit' })).toHaveFocus(); + await user.keyboard('{End}'); + expect(screen.getByRole('button', { name: 'Download' })).toHaveFocus(); + await user.keyboard('{ArrowDown}'); + expect(screen.getByRole('button', { name: 'Edit' })).toHaveFocus(); + await user.keyboard('{Escape}'); + expect(mockOnClose).toHaveBeenCalledOnce(); + expect(opener).toHaveFocus(); + unmount(); + opener.remove(); + }); + + it('reaches submenu actions by keyboard and preserves internal scrolling', async () => { + const user = userEvent.setup(); + const action = vi.fn(); + render(); + await user.keyboard('{Enter}{ArrowDown}'); + expect(screen.getByRole('button', { name: 'Folder' })).toHaveFocus(); + fireEvent.scroll(screen.getByRole('button', { name: 'Folder' }).parentElement!); + expect(mockOnClose).not.toHaveBeenCalled(); + await user.keyboard('{Enter}'); + expect(action).toHaveBeenCalledOnce(); + expect(mockOnClose).toHaveBeenCalledOnce(); + }); + it('calls onClick when item is clicked', async () => { const user = userEvent.setup(); render( diff --git a/frontend/src/__tests__/components/FolderActions.test.tsx b/frontend/src/__tests__/components/FolderActions.test.tsx new file mode 100644 index 0000000000..e4f3fd480c --- /dev/null +++ b/frontend/src/__tests__/components/FolderActions.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import type { LibraryFolderTree, Permission } from '../../api/client'; +import { FolderActions } from '../../pages/file-manager/FolderActions'; + +const folder = { id: 42, name: 'Models', children: [], project_id: 12 } as unknown as LibraryFolderTree; + +describe('FolderActions permissions (tree and mobile selector)', () => { + for (const granted of [[], ['library:update_all'], ['library:delete_all']]) { + it(`retains permission checks for ${granted.join(',') || 'read only'}`, async () => { + const onRename = vi.fn(); + const onLink = vi.fn(); + const onDelete = vi.fn(); + const user = userEvent.setup(); + render( key} + hasPermission={(permission: Permission) => granted.includes(permission)} + onRename={onRename} onLink={onLink} onDelete={onDelete} />); + for (const [label, permission, callback, argument] of [ + ['common.rename', 'library:update_all', onRename, folder], + ['fileManager.changeLink', 'library:update_all', onLink, folder], + ['common.delete', 'library:delete_all', onDelete, 42], + ] as const) { + const trigger = screen.getByRole('button', { name: 'common.actions: Models' }); + if (!screen.queryByRole('menu')) await user.click(trigger); + const item = screen.getByRole('menuitem', { name: label }); + if (granted.includes(permission)) { + expect(item).toBeEnabled(); + await user.click(item); + expect(callback).toHaveBeenCalledWith(argument); + expect(trigger).toHaveFocus(); + } else { + expect(item).toBeDisabled(); + await user.click(item); + expect(callback).not.toHaveBeenCalled(); + } + } + }); + } +}); diff --git a/frontend/src/__tests__/pages/FileManagerSliceFormats.test.tsx b/frontend/src/__tests__/pages/FileManagerSliceFormats.test.tsx index bdf6f7cd4d..64f2ad95e2 100644 --- a/frontend/src/__tests__/pages/FileManagerSliceFormats.test.tsx +++ b/frontend/src/__tests__/pages/FileManagerSliceFormats.test.tsx @@ -11,6 +11,8 @@ import { render } from '../utils'; import { FileManagerPage } from '../../pages/FileManagerPage'; import { http, HttpResponse } from 'msw'; import { server } from '../mocks/server'; +import userEvent from '@testing-library/user-event'; +import { setAuthToken } from '../../api/client'; const baseFile = { file_size: 524288, @@ -96,4 +98,48 @@ describe('FileManagerPage - STEP files and server-side slicing', () => { const hint = within(rowFor('flange.step')).getByLabelText(/STEP\/STP files cannot be sliced/i); expect(hint).toBeInTheDocument(); }); + + it('preserves the same file-type gates in portalled card menus', async () => { + vi.mocked(localStorage.getItem).mockReturnValue(null); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: 'Actions: bracket.stl' })); + expect(screen.getByRole('menuitem', { name: 'Slice', exact: true })).toBeEnabled(); + expect(screen.getByRole('menuitem', { name: 'Run with pipeline' })).toBeEnabled(); + expect(screen.queryByRole('menuitem', { name: 'Print', exact: true })).not.toBeInTheDocument(); + await user.keyboard('{Escape}'); + for (const filename of ['flange.step', 'housing.stp']) { + await user.click(screen.getByRole('button', { name: `Actions: ${filename}` })); + expect(screen.queryByRole('menuitem', { name: 'Slice', exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Run with pipeline' })).not.toBeInTheDocument(); + expect(screen.getByTitle(/STEP\/STP files cannot be sliced/i)).toBeInTheDocument(); + await user.keyboard('{Escape}'); + } + }); + + it('retains permission and ownership restrictions in the card menu', async () => { + vi.mocked(localStorage.getItem).mockReturnValue(null); + setAuthToken('test-only-token'); + server.use( + http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })), + http.get('/api/v1/auth/me', () => HttpResponse.json({ + id: 1, username: 'reader', is_admin: false, is_active: true, groups: [], + permissions: ['library:read', 'library:update_own'], + })), + http.get('/api/v1/library/files', () => HttpResponse.json(mockFiles.map(file => ({ ...file, created_by_id: 2 })))), + ); + try { + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: 'Actions: bracket.stl' })); + await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Slice', exact: true })).toBeDisabled()); + for (const name of ['Run with pipeline', 'Rename', 'Generate Thumbnail', 'Delete']) { + expect(screen.getByRole('menuitem', { name, exact: true })).toBeDisabled(); + } + expect(screen.getByRole('menuitem', { name: 'Download' })).toBeEnabled(); + expect(screen.getByRole('menuitem', { name: '3D Preview' })).toBeEnabled(); + } finally { + setAuthToken(null); + } + }); }); diff --git a/frontend/src/__tests__/pages/TouchReachableActions.test.ts b/frontend/src/__tests__/pages/TouchReachableActions.test.ts new file mode 100644 index 0000000000..ecdbfbd5e0 --- /dev/null +++ b/frontend/src/__tests__/pages/TouchReachableActions.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const source = (path: string) => readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '../..', path), 'utf8'); + +// jsdom cannot evaluate media-query opacity. These source contracts cover every +// affected owner; production browser tests verify the generated CSS and menus. +describe('touch/focus visibility contracts', () => { + for (const [path, reveal] of [ + ['pages/ArchivesPage.tsx', 'focus-visible:opacity-100'], + ['pages/FileManagerPage.tsx', 'group-focus-within:opacity-100'], + ['pages/ProjectsPage.tsx', 'focus-visible:opacity-100'], + ['pages/ProfilesPage.tsx', 'focus-visible:opacity-100'], + ['components/TagManagementModal.tsx', 'group-focus-within:opacity-100'], + ['components/EditArchiveModal.tsx', 'focus-visible:opacity-100'], + ['pages/printers/PrinterCardOverlays.tsx', 'focus-visible:opacity-100'], + ]) { + it(`${path} hides actions only for a hover-capable pointer and reveals keyboard focus`, () => { + const text = source(path); + expect(text).toContain('can-hover:opacity-0 group-hover:opacity-100'); + expect(text).toContain(reveal); + expect(text).not.toMatch(/(? { + expect(source('index.css')).toContain('@custom-variant can-hover (@media (hover: hover) and (pointer: fine));'); + for (const path of ['pages/FileManagerPage.tsx', 'pages/ArchivesPage.tsx']) { + expect(source(path)).not.toContain('useIsMobile'); + } + }); +}); diff --git a/frontend/src/components/ContextMenu.tsx b/frontend/src/components/ContextMenu.tsx index f46fa3585f..5d8dc8af46 100644 --- a/frontend/src/components/ContextMenu.tsx +++ b/frontend/src/components/ContextMenu.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, useLayoutEffect } from 'react'; +import { createPortal } from 'react-dom'; import { ChevronRight, Search } from 'lucide-react'; import { TextField } from './ui'; @@ -117,12 +118,22 @@ function SubmenuPanel({ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { const menuRef = useRef(null); + const returnFocusRef = useRef(null); const [activeSubmenu, setActiveSubmenu] = useState(null); const submenuTimeoutRef = useRef(null); const [position, setPosition] = useState({ x, y, visible: false }); const [openSubmenuLeft, setOpenSubmenuLeft] = useState(false); const [submenuPositions, setSubmenuPositions] = useState>({}); + useEffect(() => { + returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + }, []); + + useEffect(() => { + // Browsers reject focus while positioning still has visibility:hidden. + if (position.visible) menuRef.current?.querySelector('button:not(:disabled)')?.focus({ preventScroll: true }); + }, [position.visible]); + useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { @@ -132,6 +143,8 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') { + e.preventDefault(); + returnFocusRef.current?.focus({ preventScroll: true }); onClose(); } }; @@ -229,9 +242,25 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { }, 150); }; - return ( + return createPortal(
event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Tab') { + returnFocusRef.current?.focus({ preventScroll: true }); + onClose(); + return; + } + if (event.target instanceof HTMLInputElement || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const buttons = Array.from(event.currentTarget.querySelectorAll('button:not(:disabled)')); + if (!buttons.length) return; + const current = buttons.indexOf(document.activeElement as HTMLButtonElement); + const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 + : (current + (event.key === 'ArrowUp' ? -1 : 1) + buttons.length) % buttons.length; + buttons[next].focus(); + }} className="fixed z-50 min-w-[180px] max-w-[280px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1" style={{ left: position.x, @@ -299,6 +328,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
); })} - + , + document.body, ); } diff --git a/frontend/src/components/EditArchiveModal.tsx b/frontend/src/components/EditArchiveModal.tsx index 7543b9f1eb..5ffdb42579 100644 --- a/frontend/src/components/EditArchiveModal.tsx +++ b/frontend/src/components/EditArchiveModal.tsx @@ -464,7 +464,8 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr diff --git a/frontend/src/components/TagManagementModal.tsx b/frontend/src/components/TagManagementModal.tsx index 5f712eb47f..c9809ba593 100644 --- a/frontend/src/components/TagManagementModal.tsx +++ b/frontend/src/components/TagManagementModal.tsx @@ -258,7 +258,7 @@ export function TagManagementModal({ onClose }: TagManagementModalProps) { {tag.count} -
+
+ setOpen(false)} + className="min-w-[160px] max-w-[min(280px,calc(100vw-16px))] py-1"> + + + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index ca08f6cbe0..4a74583f6b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -26,6 +26,10 @@ /* Enable class-based dark mode for Tailwind v4 */ @custom-variant dark (&:where(.dark, .dark *)); +/* Hide secondary actions only for a precise, hover-capable pointer. Touch + controls stay visible at every viewport width; focus reveals desktop ones. */ +@custom-variant can-hover (@media (hover: hover) and (pointer: fine)); + @theme { /* Accent colors - use CSS variables for theming */ --color-bambu-green: var(--accent); diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index dd941f17d6..60d93495ce 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -64,7 +64,6 @@ import { openInSlicer, type SlicerType } from '../utils/slicer'; import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date'; import { useDisplayCurrency } from '../hooks/useDisplayCurrency'; import { getBedTypeInfo } from '../utils/bedType'; -import { useIsMobile } from '../hooks/useIsMobile'; import { usePageFileDrop } from '../hooks/usePageFileDrop'; import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client'; import { Card, CardContent } from '../components/Card'; @@ -180,7 +179,6 @@ function ArchiveCard({ const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission, canModify } = useAuth(); - const isMobile = useIsMobile(); const navigate = useNavigate(); const [showReprint, setShowReprint] = useState(false); const [showSliceModal, setShowSliceModal] = useState(false); @@ -217,11 +215,11 @@ function ArchiveCard({ const f3dInputRef = useRef(null); const timelapseInputRef = useRef(null); - // Fetch plates data for multi-plate browsing (lazy - only when hovering) + // Load plate navigation on pointer or keyboard intent, not hover alone. const { data: platesData } = useQuery({ queryKey: ['archive-plates', archive.id], queryFn: () => api.getArchivePlates(archive.id), - enabled: showPlateNav, // Only fetch when user hovers to see navigation + enabled: showPlateNav, staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); @@ -717,7 +715,8 @@ function ArchiveCard({
setShowPlateNav(true)} - onMouseLeave={() => setShowPlateNav(false)} + onPointerDown={() => setShowPlateNav(true)} + onFocusCapture={() => setShowPlateNav(true)} > {archive.thumbnail_path ? ( {/* Left arrow */} {/* Right arrow */} {/* Dots indicator */}
{plates.map((plate, idx) => ( )} -
e.stopPropagation()}> -
- - {showActions && ( - <> -
setShowActions(false)} /> -
- - - -
- - )} -
+
e.stopPropagation()}> +
{hasChildren && expanded && ( @@ -712,7 +664,6 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, interface FileCardProps { file: LibraryFileListItem; isSelected: boolean; - isMobile: boolean; onSelect: (id: number) => void; onDelete: (id: number) => void; onDownload: (id: number) => void; @@ -732,9 +683,7 @@ interface FileCardProps { t: TFunction; } -function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onRunPipeline, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) { - const [showActions, setShowActions] = useState(false); - +function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onRunPipeline, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) { return (
- {/* Actions - always visible on mobile, hover on desktop */} -
e.stopPropagation()}> - - {showActions && ( - <> -
setShowActions(false)} /> -
+ {/* Touch actions stay visible; desktop actions also reveal on focus. */} +
e.stopPropagation()}> + + {(close) => <> {onPrint && isSlicedLibraryFile(file) && ( )} {onRename && ( )} -
- - )} + } +
- {/* Selection checkbox - always visible on mobile, hover on desktop */} + {/* Selection indicator follows the same pointer/focus visibility. */}
{isSelected &&
}
@@ -1096,9 +1044,6 @@ export function FileManagerPage() { () => localStorage.getItem('library-show-modified') === 'true' ); - // Mobile detection for touch-friendly UI - const isMobile = useIsMobile(); - // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page) useEffect(() => { const folderParam = searchParams.get('folder'); @@ -1803,7 +1748,8 @@ export function FileManagerPage() { {/* Main content */}
{/* Mobile folder selector */} -
+
+
{ @@ -1840,6 +1786,12 @@ export function FileManagerPage() { )); })()} +
+ {selectedFolder && setRenameItem({ type: 'folder', id: folder.id, name: folder.name })} + onLink={setLinkFolder} + onDelete={(id) => setDeleteConfirm({ type: 'folder', id })} + />}
{/* Folder sidebar - resizable, hidden on mobile */} @@ -2372,7 +2324,6 @@ export function FileManagerPage() { key={file.id} file={file} isSelected={selectedFiles.includes(file.id)} - isMobile={isMobile} t={t} onSelect={handleFileSelect} onDelete={(id) => setDeleteConfirm({ type: 'file', id })} diff --git a/frontend/src/pages/ProfilesPage.tsx b/frontend/src/pages/ProfilesPage.tsx index d6e47a19ac..08ffe544bc 100644 --- a/frontend/src/pages/ProfilesPage.tsx +++ b/frontend/src/pages/ProfilesPage.tsx @@ -453,7 +453,7 @@ function PresetListItem({
{/* Actions menu */} -
e.stopPropagation()}> - - {showActions && ( - <> -
setShowActions(false)} /> -
+
e.stopPropagation()}> + + {(close) => <> -
- - )} + } +
diff --git a/frontend/src/pages/file-manager/FolderActions.tsx b/frontend/src/pages/file-manager/FolderActions.tsx new file mode 100644 index 0000000000..a0fc6f70aa --- /dev/null +++ b/frontend/src/pages/file-manager/FolderActions.tsx @@ -0,0 +1,59 @@ +import { Link2, Pencil, Trash2 } from 'lucide-react'; +import type { LibraryFolderTree, Permission } from '../../api/client'; +import { ActionMenu } from '../../components/ui/ActionMenu'; + +type Props = { + folder: LibraryFolderTree; + onRename: (folder: LibraryFolderTree) => void; + onLink: (folder: LibraryFolderTree) => void; + onDelete: (id: number) => void; + hasPermission: (permission: Permission) => boolean; + t: (key: string) => string; +}; + +/** Shared by the folder tree and the compact/mobile folder selector. */ +export function FolderActions({ folder, onRename, onLink, onDelete, hasPermission, t }: Props) { + const isLinked = folder.project_id || folder.archive_id; + return ( + + {(close) => <> + + + + } + + ); +} diff --git a/frontend/src/pages/printers/PrinterCardOverlays.tsx b/frontend/src/pages/printers/PrinterCardOverlays.tsx index defd25a67c..22dbd4aa93 100644 --- a/frontend/src/pages/printers/PrinterCardOverlays.tsx +++ b/frontend/src/pages/printers/PrinterCardOverlays.tsx @@ -228,7 +228,7 @@ export function PrinterCardOverlays({ model }: PrinterCardOverlaysProps) { {/* Delete button */}