mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
Keep card and row actions reachable without a hover-capable pointer (issue #2865)
Tailwind v4 compiles group-hover: inside @media (hover: hover) - the
shipped CSS has .group-hover\:opacity-100 sitting in exactly that block.
On a touch-only device the media query never matches, so the rule that
reveals the control is not merely never triggered: it is never applied.
A control written as opacity-0 group-hover:opacity-100 is invisible for
good. The reporter's iPhone screenshot shows the project card with
nothing where the "..." belongs, and Edit and Delete live only there.
So the hiding half is what has to depend on the pointer, not the
revealing half. A can-hover variant carries the query - the same one the
history thumbnail preview has used since it was written - and the six
controls behind it become can-hover:opacity-0 group-hover:opacity-100.
Without a hover-capable pointer no rule hides them and they simply
render; with one, nothing changes. Every reveal selector is specificity
(0,2,0) against the hider's (0,1,0), so which one wins does not depend on
where they land in the stylesheet.
Six controls were affected: the project card menu, the File Manager's
folder actions (reachable by accident today - the "wrap names" toggle
drops the hover class), duplicate preset, rename and delete tag, delete
print photo, delete plate reference.
Six more already tried to handle this, by viewport width under 768px on
the Archives cards and the File Manager's file cards. That covers a phone
and misses an iPad in landscape, which is touch-only at 1024px. They move
to the capability check and useIsMobile goes with them, along with the
isMobile prop threaded into FileCard.
opacity-0 also leaves a button focusable while invisible, so tabbing
through a card stopped on a control nobody could see. Focus now reveals
them, through group-focus-within on the wrappers and focus-visible on the
standalone buttons - neither is hover-gated.
jsdom does not evaluate media queries, so the tests pin the class
contract instead: a bare opacity-0 is the defect, because it applies
unconditionally while everything that undoes it does not.
The decorative hover reveals are left alone - the archive hash badge, the
project name overlay, the thumbnail preview, the swatch tooltips. Nothing
is unreachable there, only unseen.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Card and row actions must stay reachable without a hover-capable pointer (#2865).
|
||||
*
|
||||
* Tailwind v4 compiles `group-hover:` inside `@media (hover: hover)`, so on a
|
||||
* touch-only device the reveal rule is never applied and a control written as
|
||||
* `opacity-0 group-hover:opacity-100` is invisible for good — which is how the
|
||||
* project card's action menu and the File Manager's folder actions became
|
||||
* unusable on a phone. The fix moves the HIDING half behind a `can-hover`
|
||||
* variant, so with no such pointer the control simply keeps its own opacity.
|
||||
*
|
||||
* jsdom does not evaluate media queries, so these tests pin the class contract
|
||||
* rather than the computed style: a bare `opacity-0` is the defect, because it
|
||||
* applies unconditionally while everything that undoes it does not.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render } from '../utils';
|
||||
import { server } from '../mocks/server';
|
||||
import { ProjectsPage } from '../../pages/ProjectsPage';
|
||||
import { FileManagerPage } from '../../pages/FileManagerPage';
|
||||
import { setAuthToken } from '../../api/client';
|
||||
|
||||
/** `opacity-0` on its own — with no variant in front of it. */
|
||||
const UNCONDITIONALLY_HIDDEN = /(^|\s)opacity-0(\s|$)/;
|
||||
|
||||
const mockProjects = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Functional Parts',
|
||||
description: 'Useful household items',
|
||||
color: '#00ae42',
|
||||
archive_count: 10,
|
||||
total_print_time_seconds: 36000,
|
||||
total_filament_grams: 500,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-15T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const mockFolders = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Brackets',
|
||||
parent_id: null,
|
||||
file_count: 0,
|
||||
project_id: null,
|
||||
archive_id: null,
|
||||
project_name: null,
|
||||
archive_name: null,
|
||||
is_external: false,
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe('actions that were hover-only (#2865)', () => {
|
||||
afterEach(() => {
|
||||
setAuthToken(null);
|
||||
});
|
||||
|
||||
describe('project card', () => {
|
||||
beforeEach(() => {
|
||||
server.use(http.get('/api/v1/projects/', () => HttpResponse.json(mockProjects)));
|
||||
});
|
||||
|
||||
it('does not hide the action menu from a pointer that cannot hover', async () => {
|
||||
render(<ProjectsPage />);
|
||||
await waitFor(() => expect(screen.getByText('Functional Parts')).toBeInTheDocument());
|
||||
|
||||
const card = screen.getByText('Functional Parts').closest('div.group')!;
|
||||
const menuButton = within(card).getAllByRole('button').slice(-1)[0];
|
||||
|
||||
expect(menuButton.className).not.toMatch(UNCONDITIONALLY_HIDDEN);
|
||||
expect(menuButton.className).toContain('can-hover:opacity-0');
|
||||
});
|
||||
|
||||
it('still opens Edit and Delete once the menu is tapped', async () => {
|
||||
render(<ProjectsPage />);
|
||||
await waitFor(() => expect(screen.getByText('Functional Parts')).toBeInTheDocument());
|
||||
|
||||
const card = screen.getByText('Functional Parts').closest('div.group')!;
|
||||
const user = userEvent.setup();
|
||||
await user.click(within(card).getAllByRole('button').slice(-1)[0]);
|
||||
|
||||
expect(within(card).getByRole('button', { name: 'Edit' })).toBeInTheDocument();
|
||||
expect(within(card).getByRole('button', { name: 'Delete' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('file manager folder row', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setAuthToken('test-token', 'session');
|
||||
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: 7,
|
||||
username: 'operator1',
|
||||
is_admin: false,
|
||||
permissions: ['library:read_own', 'library:update_all', 'library:delete_all'],
|
||||
}),
|
||||
),
|
||||
http.get('/api/v1/library/folders', () => HttpResponse.json(mockFolders)),
|
||||
http.get('/api/v1/library/files', () => HttpResponse.json([])),
|
||||
http.get('/api/v1/library/stats', () =>
|
||||
HttpResponse.json({
|
||||
total_files: 0,
|
||||
total_folders: 1,
|
||||
total_size_bytes: 0,
|
||||
disk_free_bytes: 10737418240,
|
||||
disk_total_bytes: 107374182400,
|
||||
}),
|
||||
),
|
||||
http.get('/api/v1/projects/', () => HttpResponse.json([])),
|
||||
http.get('/api/v1/archives/', () => HttpResponse.json([])),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not hide the folder actions from a pointer that cannot hover', async () => {
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Brackets')).toBeInTheDocument());
|
||||
|
||||
const row = screen.getByText('Brackets').closest('div.group')!;
|
||||
// The kebab menu's wrapper is what carries the visibility classes.
|
||||
const actions = within(row).getAllByRole('button').slice(-1)[0].closest('div.flex-shrink-0')!;
|
||||
|
||||
expect(actions.className).not.toMatch(UNCONDITIONALLY_HIDDEN);
|
||||
expect(actions.className).toContain('can-hover:opacity-0');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -466,7 +466,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePhotoDelete(filename)}
|
||||
className="absolute -top-1 -right-1 p-1 bg-red-500 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="absolute -top-1 -right-1 p-1 bg-red-500 rounded-full can-hover:opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-white" />
|
||||
</button>
|
||||
|
||||
@@ -257,7 +257,7 @@ export function TagManagementModal({ onClose }: TagManagementModalProps) {
|
||||
<span className="px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray text-xs">
|
||||
{tag.count}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center gap-1 can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => startEdit(tag)}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
|
||||
|
||||
@@ -26,6 +26,16 @@
|
||||
/* Enable class-based dark mode for Tailwind v4 */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Hover-capable pointer only (#2865). Tailwind v4 compiles `hover:` AND
|
||||
`group-hover:` inside `@media (hover: hover)`, so on a touch-only device the
|
||||
reveal rule is never applied at all — a control written as
|
||||
`opacity-0 group-hover:opacity-100` stays invisible forever, which is how the
|
||||
project card's action menu became unreachable on a phone. Gating the HIDING
|
||||
half on this variant is what fixes it: with no hover-capable pointer the
|
||||
element simply keeps its natural opacity. Same query the history thumbnail
|
||||
preview already uses further down. */
|
||||
@custom-variant can-hover (@media (hover: hover) and (pointer: fine));
|
||||
|
||||
/* Restore the pointer cursor on interactive controls (#2791). Tailwind v3's
|
||||
Preflight set `button { cursor: pointer }`; v4 dropped it to match the
|
||||
browser default of `cursor: default`, so every button in the app looked
|
||||
|
||||
@@ -69,7 +69,6 @@ import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDu
|
||||
import { getCurrencySymbol } from '../utils/currency';
|
||||
import { getBedTypeInfo } from '../utils/bedType';
|
||||
import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { usePageFileDrop } from '../hooks/usePageFileDrop';
|
||||
import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client';
|
||||
import { Card, CardContent } from '../components/Card';
|
||||
@@ -311,7 +310,6 @@ function ArchiveCard({
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const { hasPermission, canModify } = useAuth();
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
// Name of the printer this archive's saved slicer AMS mapping was resolved
|
||||
// against, or undefined when there is none. Undefined also when the printer
|
||||
@@ -883,9 +881,7 @@ function ArchiveCard({
|
||||
<>
|
||||
{/* Left arrow */}
|
||||
<button
|
||||
className={`absolute left-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${
|
||||
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
className="absolute left-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentPlateIndex((prev) => {
|
||||
@@ -899,9 +895,7 @@ function ArchiveCard({
|
||||
</button>
|
||||
{/* Right arrow */}
|
||||
<button
|
||||
className={`absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${
|
||||
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentPlateIndex((prev) => {
|
||||
@@ -915,9 +909,7 @@ function ArchiveCard({
|
||||
</button>
|
||||
{/* Dots indicator */}
|
||||
<div
|
||||
className={`absolute bottom-1 left-1/2 -translate-x-1/2 flex gap-1 px-2 py-1 rounded-full bg-black/50 transition-all ${
|
||||
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
className="absolute bottom-1 left-1/2 -translate-x-1/2 flex gap-1 px-2 py-1 rounded-full bg-black/50 transition-all can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
{plates.map((plate, idx) => (
|
||||
<button
|
||||
@@ -935,11 +927,9 @@ function ArchiveCard({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Context menu button - visible on mobile, shows on hover for desktop */}
|
||||
{/* Context menu button - hover-revealed with a mouse, always there without one (#2865) */}
|
||||
<button
|
||||
className={`absolute top-2 left-2 p-1.5 rounded bg-black/50 hover:bg-black/70 transition-all ${
|
||||
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
} ${selectionMode ? 'left-10' : ''}`}
|
||||
className={`absolute top-2 left-2 p-1.5 rounded bg-black/50 hover:bg-black/70 transition-all can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 ${selectionMode ? 'left-10' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
@@ -70,7 +70,6 @@ import { FolderReadmePanel } from '../components/FolderReadmePanel';
|
||||
import { LibraryTagsModal } from '../components/LibraryTagsModal';
|
||||
import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { usePageFileDrop } from '../hooks/usePageFileDrop';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
|
||||
@@ -662,7 +661,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
|
||||
<Link2 className="w-3.5 h-3.5 text-bambu-gray hover:text-bambu-green" />
|
||||
</button>
|
||||
)}
|
||||
<div className={`flex-shrink-0 flex items-center gap-0.5 transition-opacity ${wrapNames ? '' : 'opacity-0 group-hover:opacity-100'}`} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={`flex-shrink-0 flex items-center gap-0.5 transition-opacity ${wrapNames ? '' : 'can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'}`} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowActions(!showActions)}
|
||||
@@ -748,7 +747,6 @@ function isSlicedFilename(filename: string): boolean {
|
||||
interface FileCardProps {
|
||||
file: LibraryFileListItem;
|
||||
isSelected: boolean;
|
||||
isMobile: boolean;
|
||||
onSelect: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onDownload: (id: number) => void;
|
||||
@@ -770,7 +768,7 @@ interface FileCardProps {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, canSlice, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
|
||||
function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, canSlice, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
|
||||
// Viewport coordinates rather than a flag, because the menu is rendered by
|
||||
// `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
|
||||
// card it belongs to is only ~270px tall for a bare STL, which is shorter
|
||||
@@ -954,8 +952,8 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions - always visible on mobile, hover on desktop */}
|
||||
<div className={`absolute bottom-2 right-2 transition-opacity ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`} onClick={(e) => e.stopPropagation()}>
|
||||
{/* Actions - hover-revealed with a mouse, always there without one (#2865) */}
|
||||
<div className="absolute bottom-2 right-2 transition-opacity can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
// No open/close toggle: the menu's own outside-mousedown handler
|
||||
@@ -972,11 +970,11 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection checkbox - always visible on mobile, hover on desktop */}
|
||||
{/* Selection checkbox - hover-revealed with a mouse, always there without one (#2865) */}
|
||||
<div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? 'bg-bambu-green border-bambu-green'
|
||||
: `border-white/30 bg-black/30 ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`
|
||||
: 'border-white/30 bg-black/30 can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
|
||||
}`}>
|
||||
{isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
|
||||
</div>
|
||||
@@ -1109,9 +1107,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');
|
||||
@@ -2433,7 +2428,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 })}
|
||||
|
||||
@@ -6675,7 +6675,7 @@ function PrinterCard({
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={() => handleDeleteRef(ref.index)}
|
||||
className="absolute top-1 right-1 p-0.5 bg-red-500/80 rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="absolute top-1 right-1 p-0.5 bg-red-500/80 rounded can-hover:opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
|
||||
title={t('printers.plateDetection.deleteReference')}
|
||||
>
|
||||
<X className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] text-white" />
|
||||
|
||||
@@ -501,7 +501,7 @@ function PresetListItem({
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDuplicate(); }}
|
||||
className="opacity-0 group-hover:opacity-100 text-bambu-gray hover:text-white transition-all p-1"
|
||||
className="can-hover:opacity-0 group-hover:opacity-100 focus-visible:opacity-100 text-bambu-gray hover:text-white transition-all p-1"
|
||||
title={t('profiles.presets.duplicate')}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
|
||||
@@ -730,7 +730,7 @@ function ProjectCard({ project, parentName, onClick, onEdit, onDelete, hasPermis
|
||||
{/* Actions menu */}
|
||||
<div className="relative" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="p-1.5 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors opacity-0 group-hover:opacity-100"
|
||||
className="p-1.5 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors can-hover:opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => setShowActions(!showActions)}
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -26,8 +26,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-pHbVkL6v.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C_NRLB80.css">
|
||||
<script type="module" crossorigin src="/assets/index-LeHzL3OL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-kSJGQrMr.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user