mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(dashboard): keep the trip card actions on the cover and give them contrast
The edit/duplicate/archive/delete buttons sat at opacity 0 until the card was hovered, over a white .18 fill. Two people in a row said they never found how to unarchive or rename a trip: invisible at rest, and white-on-white over a light cover even while hovering. They stay on the card now at .92, the fill is a dark scrim so the white icons carry against any photo, and the icons get a heavier stroke. The status pill and the hero badge move to the same scrim so the cover overlays read as one set. An archived card shows the restore icon rather than the archive one, which is what mobile already did, and every button gains a title next to its aria-label.
This commit is contained in:
@@ -1000,4 +1000,59 @@ describe('DashboardPage', () => {
|
||||
expect(within(grid()).queryByText('Paris Adventure')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Two people reported in a row that they could not find how to unarchive or
|
||||
// rename a trip. The controls sat at opacity 0 until the card was hovered, and
|
||||
// the archive button carried the same icon whether it would archive or restore.
|
||||
describe('FE-PAGE-DASH-036: the cover controls are findable', () => {
|
||||
const activeOnly = (trips: unknown[]) =>
|
||||
server.use(
|
||||
http.get('/api/trips', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.get('archived')) return HttpResponse.json({ trips: [] });
|
||||
return HttpResponse.json({ trips });
|
||||
}),
|
||||
);
|
||||
|
||||
it('names every action on the card, so none of them is icon-only guesswork', async () => {
|
||||
activeOnly([buildTrip({ title: 'Lisbon 2025', start_date: '2025-05-01', end_date: '2025-05-08' })]);
|
||||
const user = userEvent.setup();
|
||||
render(<DashboardPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText('Lisbon 2025').length).toBeGreaterThan(0));
|
||||
await user.click(screen.getByText('Completed'));
|
||||
const card = (await screen.findAllByText('Lisbon 2025'))
|
||||
.map(n => n.closest('.trip-card'))
|
||||
.find(Boolean) as HTMLElement;
|
||||
for (const label of ['Edit', 'Duplicate', 'Archive', 'Delete']) {
|
||||
const button = card.querySelector(`[aria-label="${label}"]`) as HTMLElement;
|
||||
expect(button).toBeTruthy();
|
||||
// A tooltip as well as the screen-reader label — the icons alone did not read.
|
||||
expect(button.getAttribute('title')).toBe(label);
|
||||
}
|
||||
});
|
||||
|
||||
it('swaps the archive icon for a restore one once the trip is archived', async () => {
|
||||
const archived = buildTrip({ title: 'Old Rome Trip', start_date: '2024-01-01', end_date: '2024-01-07', is_archived: 1 });
|
||||
server.use(
|
||||
http.get('/api/trips', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
return HttpResponse.json({ trips: url.searchParams.get('archived') ? [archived] : [] });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<DashboardPage />);
|
||||
|
||||
await user.click(screen.getByText('Archived'));
|
||||
await waitFor(() => expect(screen.getByText('Old Rome Trip')).toBeInTheDocument());
|
||||
|
||||
const card = screen.getByText('Old Rome Trip').closest('.trip-card') as HTMLElement;
|
||||
const restore = card.querySelector('[aria-label="Restore"]') as HTMLElement;
|
||||
expect(restore).toBeTruthy();
|
||||
// lucide stamps the icon name on the svg, which is how the archive and the
|
||||
// restore variant tell themselves apart.
|
||||
expect(restore.querySelector('svg')?.getAttribute('class')).toContain('archive-restore');
|
||||
expect(card.querySelector('[aria-label="Archive"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
MS_PER_DAY, daysUntil, getTripStatus,
|
||||
} from './dashboard/dashboardModel'
|
||||
import {
|
||||
Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin,
|
||||
Plus, Edit2, Trash2, Archive, ArchiveRestore, Copy, ArrowRight, MapPin,
|
||||
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
|
||||
LayoutGrid, List, Ticket, X, CalendarPlus, ParkingSquare,
|
||||
} from 'lucide-react'
|
||||
@@ -304,6 +304,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
|
||||
const heroPlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot === 'hero')
|
||||
const stop = (e: React.MouseEvent, fn: () => void) => { e.stopPropagation(); fn() }
|
||||
const status = getTripStatus(trip)
|
||||
const archiveLabel = trip.is_archived ? t('dashboard.restore') : t('dashboard.archive')
|
||||
const start = splitDate(trip.start_date, locale)
|
||||
const end = splitDate(trip.end_date, locale)
|
||||
|
||||
@@ -404,10 +405,10 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
|
||||
{badge}
|
||||
</div>
|
||||
<div className="hero-tools">
|
||||
<button className="hero-tool" aria-label={t('common.edit')} onClick={(e) => stop(e, onEdit)}><Edit2 size={16} /></button>
|
||||
<button className="hero-tool" aria-label={t('dashboard.aria.duplicate')} onClick={(e) => stop(e, onCopy)}><Copy size={16} /></button>
|
||||
<button className="hero-tool" aria-label={trip.is_archived ? t('dashboard.restore') : t('dashboard.archive')} onClick={(e) => stop(e, onArchive)}><Archive size={16} /></button>
|
||||
<button className="hero-tool" aria-label={t('common.delete')} onClick={(e) => stop(e, onDelete)}><Trash2 size={16} /></button>
|
||||
<button className="hero-tool" aria-label={t('common.edit')} title={t('common.edit')} onClick={(e) => stop(e, onEdit)}><Edit2 size={17} strokeWidth={2.2} /></button>
|
||||
<button className="hero-tool" aria-label={t('dashboard.aria.duplicate')} title={t('dashboard.aria.duplicate')} onClick={(e) => stop(e, onCopy)}><Copy size={17} strokeWidth={2.2} /></button>
|
||||
<button className="hero-tool" aria-label={archiveLabel} title={archiveLabel} onClick={(e) => stop(e, onArchive)}>{trip.is_archived ? <ArchiveRestore size={17} strokeWidth={2.2} /> : <Archive size={17} strokeWidth={2.2} />}</button>
|
||||
<button className="hero-tool" aria-label={t('common.delete')} title={t('common.delete')} onClick={(e) => stop(e, onDelete)}><Trash2 size={17} strokeWidth={2.2} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -535,6 +536,7 @@ function TripCard({ trip, locale, badges, onOpen, onEdit, onCopy, onArchive, onD
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const status = getTripStatus(trip)
|
||||
const archiveLabel = trip.is_archived ? t('dashboard.restore') : t('dashboard.archive')
|
||||
const start = splitDate(trip.start_date, locale)
|
||||
const end = splitDate(trip.end_date, locale)
|
||||
const until = daysUntil(trip.start_date)
|
||||
@@ -557,10 +559,10 @@ function TripCard({ trip, locale, badges, onOpen, onEdit, onCopy, onArchive, onD
|
||||
: <div style={{ width: '100%', height: '100%', background: tripGradient(trip.id) }} />}
|
||||
<div className={`trip-status ${statusClass}`}><span className="indicator" /> {statusLabel}</div>
|
||||
<div className="trip-actions">
|
||||
<button className="trip-action-btn" aria-label={t('common.edit')} onClick={(e) => stop(e, onEdit)}><Edit2 size={16} /></button>
|
||||
<button className="trip-action-btn" aria-label={t('dashboard.aria.duplicate')} onClick={(e) => stop(e, onCopy)}><Copy size={16} /></button>
|
||||
<button className="trip-action-btn" aria-label={trip.is_archived ? t('dashboard.restore') : t('dashboard.archive')} onClick={(e) => stop(e, onArchive)}><Archive size={16} /></button>
|
||||
<button className="trip-action-btn" aria-label={t('common.delete')} onClick={(e) => stop(e, onDelete)}><Trash2 size={16} /></button>
|
||||
<button className="trip-action-btn" aria-label={t('common.edit')} title={t('common.edit')} onClick={(e) => stop(e, onEdit)}><Edit2 size={17} strokeWidth={2.2} /></button>
|
||||
<button className="trip-action-btn" aria-label={t('dashboard.aria.duplicate')} title={t('dashboard.aria.duplicate')} onClick={(e) => stop(e, onCopy)}><Copy size={17} strokeWidth={2.2} /></button>
|
||||
<button className="trip-action-btn" aria-label={archiveLabel} title={archiveLabel} onClick={(e) => stop(e, onArchive)}>{trip.is_archived ? <ArchiveRestore size={17} strokeWidth={2.2} /> : <Archive size={17} strokeWidth={2.2} />}</button>
|
||||
<button className="trip-action-btn" aria-label={t('common.delete')} title={t('common.delete')} onClick={(e) => stop(e, onDelete)}><Trash2 size={17} strokeWidth={2.2} /></button>
|
||||
</div>
|
||||
<div className="trip-cover-content">
|
||||
<h3 className="trip-name">{trip.title}</h3>
|
||||
|
||||
@@ -191,11 +191,12 @@
|
||||
.trek-dash .hero-top { display: flex; justify-content: space-between; align-items: start; }
|
||||
.trek-dash .hero-badge {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: oklch(1 0 0 / .14);
|
||||
background: oklch(0.22 0.01 260 / .5);
|
||||
backdrop-filter: blur(14px) saturate(1.4); -webkit-backdrop-filter: blur(14px) saturate(1.4);
|
||||
border: 1px solid oklch(1 0 0 / .2); padding: 8px 14px 8px 12px;
|
||||
border: 1px solid oklch(1 0 0 / .32); padding: 8px 14px 8px 12px;
|
||||
border-radius: 999px; font-size: 12px; font-weight: 500;
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
box-shadow: 0 2px 8px oklch(0 0 0 / .26);
|
||||
}
|
||||
.trek-dash .hero-badge .pulse {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
@@ -208,15 +209,18 @@
|
||||
100% { box-shadow: 0 0 0 0 oklch(0.84 0.18 85 / 0); }
|
||||
}
|
||||
.trek-dash .hero-tools { display: flex; gap: 8px; }
|
||||
/* Same story as .trip-action-btn: a dark scrim instead of a white .14 fill, so
|
||||
the tools read against a bright hero photo too. */
|
||||
.trek-dash .hero-tool {
|
||||
width: 38px; height: 38px; border-radius: 50%;
|
||||
background: oklch(1 0 0 / .14);
|
||||
width: 40px; height: 40px; border-radius: 50%;
|
||||
background: oklch(0.22 0.01 260 / .5);
|
||||
backdrop-filter: blur(14px) saturate(1.4); -webkit-backdrop-filter: blur(14px) saturate(1.4);
|
||||
border: 1px solid oklch(1 0 0 / .2); color: #fff;
|
||||
border: 1px solid oklch(1 0 0 / .32); color: #fff;
|
||||
box-shadow: 0 2px 8px oklch(0 0 0 / .26);
|
||||
display: grid; place-items: center; transition: background .15s, transform .12s;
|
||||
}
|
||||
.trek-dash .hero-tool:hover { background: oklch(1 0 0 / .26); transform: scale(1.05); }
|
||||
.trek-dash .hero-tool svg { width: 16px; height: 16px; }
|
||||
.trek-dash .hero-tool:hover { background: oklch(0.22 0.01 260 / .78); transform: scale(1.05); }
|
||||
.trek-dash .hero-tool svg { width: 17px; height: 17px; }
|
||||
.trek-dash .hero-title-block { align-self: end; padding-bottom: 24px; }
|
||||
.trek-dash .hero-eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
@@ -435,26 +439,36 @@
|
||||
.trek-dash .trip-cover img { width: 100%; height: 100%; object-fit: cover; transition: transform .6s cubic-bezier(.2,.7,.2,1); }
|
||||
.trek-dash .trip-card:hover .trip-cover img { transform: scale(1.04); }
|
||||
.trek-dash .trip-cover::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, oklch(0 0 0 / 0) 40%, oklch(0 0 0 / .55) 100%); }
|
||||
/* Shares the dark scrim of .trip-action-btn so both overlays on the cover read
|
||||
as one set, and the label survives a bright photo behind it. */
|
||||
.trek-dash .trip-status {
|
||||
position: absolute; top: 16px; left: 16px; z-index: 1;
|
||||
display: inline-flex; align-items: center; gap: 7px; padding: 6px 11px 6px 9px;
|
||||
border-radius: 999px; font-size: 11.5px; font-weight: 500; letter-spacing: 0.02em;
|
||||
background: oklch(1 0 0 / .18); backdrop-filter: blur(14px) saturate(1.4); -webkit-backdrop-filter: blur(14px) saturate(1.4);
|
||||
border: 1px solid oklch(1 0 0 / .22); color: #fff; text-transform: uppercase;
|
||||
background: oklch(0.22 0.01 260 / .58); backdrop-filter: blur(14px) saturate(1.4); -webkit-backdrop-filter: blur(14px) saturate(1.4);
|
||||
border: 1px solid oklch(1 0 0 / .34); color: #fff; text-transform: uppercase;
|
||||
box-shadow: 0 2px 8px oklch(0 0 0 / .28);
|
||||
}
|
||||
.trek-dash .trip-status .indicator { width: 6px; height: 6px; border-radius: 50%; background: oklch(0.84 0.16 145); }
|
||||
.trek-dash .trip-status.completed .indicator { background: oklch(0.75 0.02 220); }
|
||||
.trek-dash .trip-status.upcoming .indicator { background: oklch(0.84 0.18 85); }
|
||||
.trek-dash .trip-status.idea .indicator { background: oklch(0.78 0.14 280); }
|
||||
.trek-dash .trip-actions { position: absolute; top: 14px; right: 14px; z-index: 1; display: flex; gap: 6px; opacity: 0; transition: opacity .2s; }
|
||||
/* The actions used to sit at opacity 0 until the card was hovered, over a white
|
||||
.18 glass fill. Two people in a row reported they never found the archive and
|
||||
rename buttons — invisible at rest, and on a light cover the white-on-white
|
||||
fill hid them even while hovering. They stay on the card now, and the fill is
|
||||
a dark scrim so the white icons carry against any photo. */
|
||||
.trek-dash .trip-actions { position: absolute; top: 14px; right: 14px; z-index: 1; display: flex; gap: 6px; opacity: .92; transition: opacity .2s; }
|
||||
.trek-dash .trip-card:hover .trip-actions { opacity: 1; }
|
||||
.trek-dash .trip-action-btn {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: oklch(1 0 0 / .18); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
|
||||
border: 1px solid oklch(1 0 0 / .22); color: #fff; display: grid; place-items: center; transition: background .15s;
|
||||
width: 38px; height: 38px; border-radius: 50%;
|
||||
background: oklch(0.22 0.01 260 / .58); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
|
||||
border: 1px solid oklch(1 0 0 / .34); color: #fff; display: grid; place-items: center;
|
||||
box-shadow: 0 2px 8px oklch(0 0 0 / .28);
|
||||
transition: background .15s, transform .12s;
|
||||
}
|
||||
.trek-dash .trip-action-btn:hover { background: oklch(1 0 0 / .3); }
|
||||
.trek-dash .trip-action-btn svg { width: 16px; height: 16px; }
|
||||
.trek-dash .trip-action-btn:hover { background: oklch(0.22 0.01 260 / .82); transform: scale(1.06); }
|
||||
.trek-dash .trip-action-btn svg { width: 17px; height: 17px; }
|
||||
.trek-dash .trip-cover-content { position: absolute; left: 18px; right: 18px; bottom: 16px; z-index: 1; color: #fff; }
|
||||
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; text-shadow: 0 1px 7px oklch(0 0 0 / .3), 0 1px 2px oklch(0 0 0 / .38); }
|
||||
.trek-dash .trip-where { margin-top: 4px; font-size: 13px; opacity: .85; display: flex; align-items: center; gap: 6px; }
|
||||
@@ -686,8 +700,8 @@
|
||||
.trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; }
|
||||
.trek-dash .add-trip-card { min-height: 180px; }
|
||||
|
||||
/* Touch devices have no hover — keep the edit/copy/archive/delete actions
|
||||
visible at all times instead of revealing them on hover. */
|
||||
/* Touch devices have no hover state to lift the actions to full strength, so
|
||||
skip the resting dim entirely. */
|
||||
.trek-dash .trip-actions { opacity: 1; }
|
||||
|
||||
/* Compact list row on mobile — keeps the list view distinct from the grid. The
|
||||
|
||||
Reference in New Issue
Block a user